可读性
This chapter contains considerations about API consistency and the following recommendations:
- Use builder DSL
- Use constructor-like functions where applicable
- Use member and extension functions appropriately
- Avoid using Boolean arguments in functions
API consistency
A consistent and well-documented API is crucial for a good development experience. The same is valid for argument order, overall naming scheme, and overloads. Also, it's worth documenting all conventions.
For example, if one of your methods accepts offset
and length
as parameters, then so should other methods instead of
accepting startIndex
and endIndex
. Parameters like these are most likely of Int
or Long
type, and thus it's
very easy to confuse them.
The same works for parameter order: Keep it consistent between methods and overloads. Otherwise, users of your library might guess incorrectly the order they should pass arguments.
Here is an example of both preserving the parameter order and consistent naming:
fun String.chop(length: Int): String = substring(0, length)
fun String.chop(length: Int, startIndex: Int) =
substring(startIndex, length + startIndex)
If you have many lookalike methods, name them consistently and predictably. This is how the stdlib
API works:
there are methods first()
and firstOrNull()
, single()
and singleOrNull()
, and so on. It's clear from their names
that they are all pairs, and some of them might return null
while others might throw an exception.
Use builder DSL
"Builder" is a well-known pattern in development. It allows you to build a complex entity, not in a single expression, but gradually while getting more information. When you need to use a builder, it's better to write it using builder DSL — this is binary-compatible and more idiomatic.
A canonical example of a Kotlin builder DSL is kotlinx.html
. Consider this example:
header("modal-card-head") {
p("modal-card-title") {
+book.book.name
}
button(classes = "delete") {
attributes["aria-label"] = "close"
attributes["_"] = closeModalScript
}
}
It could be implemented as a traditional builder. But this is considerably more verbose:
headerBuilder()
.addClasses("modal-card-head")
.addElement(
pBuilder()
.addClasses("modal-card-title")
.addContent(book.book.name)
.build()
)
.addElement(
buttonBuilder()
.addClasses("delete")
.addAttribute("aria-label", "close")
.addAttribute("_", closeModalScript)
.build()
)
.build()
It has too many details that you don't necessarily need to know and requires you to build each entity at the end.
The situation worsens further if you need to generate builder's content dynamically in a loop. In this scenario, you have to instantiate a variable and dynamically overwrite it:
var buttonBuilder = buttonBuilder()
.addClasses("delete")
for ((attributeName, attributeValue) in attributes) {
buttonBuilder = buttonBuilder.addAttribute(attributeName, attributeValue)
}
buttonBuilder.build()
Inside the builder DSL, you can directly call a loop and all necessary DSL calls:
div("tags") {
for (genre in book.genres) {
span("tag is-rounded is-normal is-info is-light") {
+genre
}
}
}
Keep in mind that inside curly braces it's impossible to check at compile time if you have set all the required attributes.
To avoid this, put required arguments as function arguments, not as builder's properties. For example, if you want href
to be a mandatory HTML attribute, your function will look like:
fun a(href: String, block: A.() -> Unit): A
And not just:
fun a(block: A.() -> Unit): A
Builder DSLs are backward compatible as long as you don't delete anything from them. Typically this isn't a problem because most developers just add more properties to their builder classes over time.
Use constructor-like functions where applicable
Sometimes, you can simplify your API's appearance by using constructor-like functions. A constructor-like function is a function whose name starts with a capital letter, so it looks like a constructor. This approach can make your library easier to understand.
For example, you want to introduce an Option type in your library:
sealed interface Option<T>
class Some<T : Any>(val t: T) : Option<T>
object None : Option<Nothing>
You can define implementations of all the Option
interface methods – map()
, flatMap()
, and so on. But each time
your API users create such an Option
, they must write extra logic to check what they create. For example:
fun findById(id: Int): Option<Person> {
val person = db.personById(id)
return if (person == null) None else Some(person)
}
Instead of having to write the same check each time, you can add just one line to your API:
fun <T> Option(t: T?): Option<out T & Any> =
if (t == null) None else Some(t)
// Usage of the code above:
fun findById(id: Int): Option<Person> = Option(db.personById(id))
Now, creating a valid Option
is a no-brainer – just call Option(x)
and you have a null-safe, purely functional Option idiom.
Another use case for using a constructor-like function is when you need to return "hidden" things: a private instance, or an internal object. For example, let's look at a method from the standard library:
public fun <T> listOf(vararg elements: T): List<T> =
if (elements.isNotEmpty()) elements.asList() else emptyList()
In the code above, emptyList()
returns the following:
internal object EmptyList : List<Nothing>, Serializable, RandomAccess
You can write a constructor-like function to lower the cognitive complexity of your code and reduce the size of your API:
fun <T> List(): List<T> = EmptyList
// Usage of the code above:
public fun <T> listOf(vararg elements: T): List<T> =
if (elements.isNotEmpty()) elements.asList() else List()
Use member and extension functions appropriately
Write only the very core of the API as member functions and everything else as extension functions. It allows you to clearly show to the reader what's the core functionality and what's not.
For example, consider a class for a graph:
class Graph {
private val _vertices: MutableSet<Int> = mutableSetOf()
private val _edges: MutableMap<Int, MutableSet<Int>> = mutableMapOf()
fun addVertex(vertex: Int) {
_vertices.add(vertex)
}
fun addEdge(vertex1: Int, vertex2: Int) {
_vertices.add(vertex1)
_vertices.add(vertex2)
_edges.getOrPut(vertex1) { mutableSetOf() }.add(vertex2)
_edges.getOrPut(vertex2) { mutableSetOf() }.add(vertex1)
}
val vertices: Set<Int> get() = _vertices
val edges: Map<Int, Set<Int>> get() = _edges
}
There is a bare minimum of vertices and edges as private variables, functions to add vertices and edges, and accessor functions that return an immutable representation of the current state.
You can add all the remaining functionality outside the class:
fun Graph.getNumberOfVertices(): Int = vertices.size
fun Graph.getNumberOfEdges(): Int = edges.size
fun Graph.getDegree(vertex: Int): Int = edges[vertex]?.size ?: 0
So, only properties, overrides, and accessors should be members.
Avoid using Boolean arguments in functions
It's almost impossible to understand what the purpose of a Boolean
argument is just by reading code anywhere except
in IDEs, for example, on a version control system site. Using named arguments can help
to clarify this, but for now in IDEs, there is no way to force developers to use them. Another option is to create a function
that contains the action of the Boolean
argument and give this function a descriptive name.
For example, in the standard library there are two functions for map()
:
fun map(transform: (T) -> R): List<R>
fun mapNotNull(transform: (T) -> R?): List<R>
It was possible to add something like map(filterNulls: Boolean)
and write code like this:
listOf(1, null, 2).map(false) { it.toString() }
From reading this code, it's tough to infer what false
actually means. However, if you use the mapNotNull()
function,
you can understand the logic at first glance:
listOf(1, null, 2).mapNotNull { it.toString() }
What's next?
Learn about API's: