可预测性

This chapter contains the following recommendations:

Use sealed interfaces

Interfaces in your API are usually necessary when you need to have an abstraction from an implementation. If you have to use interfaces, consider using sealed interfaces. This is especially important if you don’t want your API’s users to extend your hierarchy.

Remember that adding a new implementation to a sealed interface will immediately make a user’s existing code invalid.

可预测性 - 图1

For example, JSON types can be of six types: object, array, number, string, boolean, and null. Creating a generic interface JsonElement can result in errors because a user can accidentally define a new implementation of JsonElement, which could break your code. Instead, you can make interface JsonElement sealed and add an implementation for each type:

  1. sealed interface JsonElement
  2. class JsonNumber(val value: Number) : JsonElement
  3. class JsonObject(val values: Map<String, JsonElement>) : JsonElement
  4. class JsonArray(val values: List<JsonElement>) : JsonElement
  5. class JsonBoolean(val value: Boolean) : JsonElement
  6. class JsonString(val value: String) : JsonElement
  7. object JsonNull : JsonElement

This approach helps you avoid mistakes on both the library and the client sides.

The key benefit of using sealed types comes into play when you use them in a when expression. If it’s possible to verify that the statement covers all cases, you don’t need to add an else clause to the statement:

  1. fun processJson(json: JsonElement) = when (json) {
  2. is JsonNumber -> { /* Process as a number */ }
  3. is JsonObject -> { /* Process as an object */ }
  4. is JsonArray -> { /* Process as an array */ }
  5. is JsonBoolean -> { /* Process as a boolean */ }
  6. is JsonString -> { /* Process as a string */ }
  7. is JsonNull -> { /* Process as null */ }
  8. // `else` clause is not required because all the cases are covered
  9. }

Hide implementations with sealed classes

If you have a sealed interface in your API, it doesn’t mean that you should expose all its implementations in your API, too. Minimizing is typically better. If you need to avoid leaky abstractions or want to prevent API users from extending your interfaces, consider using sealed classes or interfaces with your internal implementations, too.

For example, a library that works with different databases can have an interface of a database response like this:

  1. sealed interface DBResponse {
  2. operator fun <T> get(columnName: String): Sequence<T>
  3. }

Exposing implementations of this interface, such as SQLiteResponse or MongoResponse, to API users is a leaky abstraction, and it complicates the support of this API. In such a library, you might handle only your implementations of DBResponse. If a user passes their implementation of DBResponse into a library’s method accepting responses, it can cause an error. Using sealed interfaces and classes prevents this.

Validate your inputs and state

Validate inputs with the require() function

It’s possible to misuse an API. To help your users work with your API correctly, you should validate inputs as early as possible with the require() function.

For example, this is a simple library function that saves users to some external API:

  1. fun saveUser(username: String, password: String) {
  2. api.saveUser(User(username, password))
  3. }

You should perform validation on the function’s arguments to make sure that everything behaves as expected. For example, check that username is unique and not empty, even if you have already defined these constraints in your database:

  1. fun saveUser(username: String, password: String) {
  2. require(username.isNotBlank()) { "Username should not be blank" }
  3. require(api.usernameAvailable(username)) { "Username $username is already taken" }
  4. require(password.isNotBlank()) { "Password should not be blank" }
  5. require(password.length > 6) { "Password should contain at least 7 letters" }
  6. require(
  7. /* Some complex check */
  8. ) { "..." }
  9. api.saveUser(User(username, password))
  10. }

This way you ensure that your user doesn’t need to dig into complex stack traces that lead to the database. In the event of an exception, it will be an IllegalArgumentException with a meaningful message, not a generic database exception.

If you have implemented input validation, you should also document these checks.

可预测性 - 图2

Validate state with the check() function

The same recommendations apply to checking the internal state. The most obvious example is InputStream because you can’t read from a closed input stream.

Consider the class InputStream with a readByte() method and its usage:

  1. class InputStream : Closeable {
  2. private var open = true
  3. fun readByte(): Byte { /* Read and return one byte */ }
  4. override fun close() {
  5. // Dispose of the underlying resource
  6. open = false
  7. }
  8. }
  9. fun readTwoBytes(inputStream: InputStream): Pair<Byte, Byte> {
  10. val first = inputStream.use { it.readByte() }
  11. val second = inputStream.readByte()
  12. return Pair(first, second)
  13. }

The readTwoBytes() method has to throw an IllegalStateException because use{} closes a Closeable input stream, and a user shouldn’t be able to read from a closed stream. To implement this, modify the code of the readByte() function:

  1. fun readByte(): Byte {
  2. check(open) { "Can't read from the already closed stream" }
  3. // Read and return one byte
  4. }

In the example above, the check() function is used, not require(). These functions throw different exceptions: require() throws an IllegalArgumentException, whereas check() throws an IllegalStateException. This difference might become significant when debugging.

Avoid arrays in public signatures

Arrays are always mutable, and Kotlin is built around safe – read-only or immutable – objects. If you have to use arrays in your API, copy them before passing them anywhere so that you can check that they have not been modified. As an alternative, use read-only and mutable collections according to your intentions. Generally, it is best to avoid using arrays, and if you must, do so with extra caution.

For example, enum classes in Kotlin have the values() function that returns an array of all elements of the enum. If the array is not copied, a user is able to rewrite the elements:

  1. enum class Test { A, B }
  2. fun main() { Test.values()[0] = Test.B }

If you cache values inside the enum, the cache will be corrupted after running the code above. If the values are not cached, it’s an additional runtime overhead for each call of the values() function.

Avoid varargs

A varargvariable number of arguments – works as an array under the hood, but the array elements are passed individually to the function, not the whole array. This operation is costly because it’s copying the same array repeatedly.

Consider the following code:

  1. fun printElements(delimiter: String, vararg elements: String) {
  2. for (i in elements.indices) {
  3. print(elements[i])
  4. if (i < elements.lastIndex) print(delimiter)
  5. }
  6. }
  7. fun printWithSpace(vararg elements: String) {
  8. printElements(" ", *elements)
  9. }
  10. fun main() {
  11. printWithSpace("x", "y", "z")
  12. }

The printElements() function prints all strings from the vararg argument elements with a delimiter, and the printWithSpace() function calls printElements() with the delimiter defined as a space. The code looks innocent: you just pass elements from printWithSpace() to printElements(). Without the spread operator *, the code won’t compile, but with it, the array is actually copied before being passed to the printElements() function. The longer the chain is, the more copies are created and the bigger the unexpected memory overhead is.

What’s next?

Learn about APIs’: