As you get older, you begin to realize that the biggest enemy in your team’s code is “complexity.” Unreasonable complexity is a major factor that reduces code quality and increases communication costs.
Kotlin has many ways to reduce code complexity. This article explores simple and complex relationships in two common business scenarios. If I want to sum up this relationship in one sentence, I like this one best: “Behind all simplicity lies complexity.”
Starting threads and reading files are two fairly common scenarios in Android development. The realization of Java and Kotlin is given respectively. While marveling at the great gap between the expressive power of the two languages, the complexity behind Kotlin’s simple grammar is analyzed layer by layer.
Starting a thread
To start with a simple business scenario, start a new thread in Java with the following code:
Thread thread = new Thread() {
@Override
public void run(a) {
doSomething() // Business logic
super.run(); }}; thread.setDaemon(false);
thread.setPriority(-1);
thread.setName("thread");
thread.start();
Copy the code
Starting a thread is a common operation where all code except doSomething() is generic. Do you copy and paste this piece of code every time you start a thread? No grace! It has to be abstracted into a static method that can be called everywhere:
public class ThreadUtil {
public static Thread startThread(Callback callback) {
Thread thread = new Thread() {
@Override
public void run(a) {
if(callback ! =null) callback.action();
super.run(); }}; thread.setDaemon(false);
thread.setPriority(-1);
thread.setName("thread");
thread.start();
return thread;
}
public interface Callback {
void action(a); }}Copy the code
Take a closer look at the complexity introduced here, a new class ThreadUtil and static method startThread(), and a new interface Callback.
You can then build the thread like this:
ThreadUtil.startThread( new Callback() {
@Override
public void action(a) { doSomething(); }})Copy the code
Compare this to Kotlin’s solution thread() :
public fun thread(
start: Boolean = true,
isDaemon: Boolean = false,
contextClassLoader: ClassLoader? = null,
name: String? = null,
priority: Int = - 1,
block: () -> Unit
): Thread {
val thread = object : Thread() {
public override fun run(a) {
block()
}
}
if (isDaemon)
thread.isDaemon = true
if (priority > 0)
thread.priority = priority
if(name ! =null)
thread.name = name
if(contextClassLoader ! =null)
thread.contextClassLoader = contextClassLoader
if (start)
thread.start()
return thread
}
Copy the code
The thread() method hides all the details of the build thread inside the method.
You can then start a new thread like this:
thread { doSomething() }
Copy the code
Behind this brevity is a set of syntax features:
1. Top-level functions
In Kotlin, functions that are defined outside of a class and are not part of any class are called top-level functions. Thread () is such a function. The advantage of this definition is that the function can be easily accessed from anywhere.
When Kotlin’s top-level functions are compiled into Java code, they become static functions in a class named after the name of the name of the top-level function +Kt.
2. Higher-order functions
A function is a higher-order function if its argument or return value is a lambda expression.
The last argument to the thread() method is a lambda expression. In Kotlin, you can dispense with parentheses when calling a function with a single argument of type lambda. Hence the neat call thread {doSomething()}.
3. Parameter default values & Named parameters
The thread() function contains six arguments. Why is it allowed to pass only the last argument? Because the rest of the parameters are defined with default values. This syntax feature is called parameter default values.
Of course, you can also ignore the default value and re-assign the parameter:
thread(isDaemon = true) { doSomething() }
Copy the code
When you want to reassign a parameter, instead of overwriting the rest of the parameters, you can simply use the parameter name = parameter value. This syntax feature is called named parameters
Read the file line by line
Let’s look at a slightly more complex business scenario: “Read every line in a file and print it.” The Java implementation looks like this:
File file = new File(path)
BufferedReader bufferedReader = null;
try {
bufferedReader = new BufferedReader(new InputStreamReader(new FileInputStream(file)));
String line;
// Loop through each line in the file and print it
while((line = bufferedReader.readLine()) ! =null) { System.out.println(line); }}catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} finally {
// Close the resource
if(bufferedReader ! =null) {
try {
bufferedReader.close();
} catch(IOException e) { e.printStackTrace(); }}}Copy the code
Compare Kotlin’s solution:
File(path).readLines().foreach { println(it) }
Copy the code
In one sentence, even if you haven’t studied Kotlin you can guess what this is about, the semantics are so simple and clear. Such code is easy to write and easy to read.
It’s simple because Kotlin layers and hides complexity behind various syntactic features.
1. Extension methods
Lift the veil of simplicity and explore the complexity behind:
// Extend the method readLines() for File
public fun File.readLines(charset: Charset = Charsets.UTF_8): List<String> {
// Build a list of strings
val result = ArrayList<String>()
// Iterates through each line of the file and adds the contents to the list
forEachLine(charset) { result.add(it) }
// Return the list
return result
}
Copy the code
Extension methods are the syntax that Kotlin uses to add methods to a class outside of the class, using the class name. Method name () expression.
To compile Kotlin into Java, the extension method is to add a static method:
final class FilesKt__FileReadWriteKt {
// The first argument to the static function is File
public static final List readLines(@NotNull File $this$readLines, @NotNull Charset charset) {
Intrinsics.checkNotNullParameter($this$readLines, "$this$readLines");
Intrinsics.checkNotNullParameter(charset, "charset");
final ArrayList result = new ArrayList();
FilesKt.forEachLine($this$readLines, charset, (Function1)(new Function1() {
public Object invoke(Object var1) {
this.invoke((String)var1);
return Unit.INSTANCE;
}
public final void invoke(@NotNull String it) {
Intrinsics.checkNotNullParameter(it, "it"); result.add(it); }}));return(List)result; }}Copy the code
The first argument in a static method is an instance of the object being extended, so the class instance and its public methods can be accessed in an extension method using this.
The semantics of file.readlines () are straightforward: walk through each line of the File, add it to the list, and return.
The complexity is hidden in forEachLine(), which is also an extension to File. In this case, it should be this.foreachline (charset) {result.add(it)}. This can usually be omitted. ForEachLine () is a good name because it looks like you’re traversing every line of the file.
public fun File.forEachLine(charset: Charset = Charsets.UTF_8, action: (line: String) - >Unit): Unit {
BufferedReader(InputStreamReader(FileInputStream(this), charset)).forEachLine(action)
}
Copy the code
We layer the File around the forEachLine() to form a BufferReader instance and call the Reader extension method forEachLine() :
public fun Reader.forEachLine(action: (String) - >Unit): Unit =
useLines { it.forEach(action) }
Copy the code
ForEachLine () calls the extension method useLines(), which is the same as the Reader method. The slight difference in name indicates that useLines() completes the integration of all lines of the file, and that the results of the integration are traversable.
2. The generic
Which class integrates a set of elements and can be traversed? Continuing down the call chain:
public inline fun <T> Reader.useLines(block: (Sequence<String- > >)T): T =
buffered().use { block(it.lineSequence()) }
Copy the code
Reader is buffered in useLines() :
public inline fun Reader.buffered(bufferSize: Int = DEFAULT_BUFFER_SIZE): BufferedReader =
// If it is already a BufferedReader, return it directly, otherwise wrap another layer
if (this is BufferedReader) this else BufferedReader(this, bufferSize)
Copy the code
Then we call use(), using a BufferReader:
// The Closeable extension method
public inline fun
T.use(block: (T) - >R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
var exception: Throwable? = null
try {
// Trigger business logic (extended object instance is passed in)
return block(this)}catch (e: Throwable) {
exception = e
throw e
} finally {
// Shut down resources anyway
when {
apiVersionIsAtLeast(1.1.0) - >this.closeFinally(exception)
this= =null -> {}
exception == null -> close()
else ->
try {
close()
} catch (closeException: Throwable) {}
}
}
}
Copy the code
This time the extension function is not a concrete class, but a generic type bounded by Closeable, which adds a new use() method for all classes that can be closed.
In the use() extension method, the lambda expression block represents the business logic, to which the extension object is passed as an argument. The business logic is executed ina try-catch block, and the resource is finally closed in the finally. The upper layer can use this extension method with special ease, because it no longer needs to worry about exception catching and resource closing.
Overloaded operator & convention
In the scenario of reading the contents of a file, the business logic in use() is to convert the BufferReader into LineSequence and then traverse it. How does traversal and casting work here?
// Convert BufferReader to Sequence
public fun BufferedReader.lineSequence(a): Sequence<String> =
LinesSequence(this).constrainOnce()
Copy the code
Again, through the extension method, we directly construct the LineSequence object and pass in the BufferedReader. This by combination type conversion and decorator pattern is similar (about decorator pattern of explanation can click on the use of combination of design patterns | beauty the decorator pattern in the camera)
LineSequence is a Sequence:
/ / sequence
public interface Sequence<out T> {
// Define how to build iterators
public operator fun iterator(a): Iterator<T>
}
/ / the iterator
public interface Iterator<out T> {
// Get the next element
public operator fun next(a): T
// Determine if there are any subsequent elements
public operator fun hasNext(a): Boolean
}
Copy the code
Sequence is an interface that defines how to build an iterator. An iterator is also an interface that defines how to get the next element and whether there are any subsequent elements.
All three methods in both interfaces are modified by the reserved word operator, which represents an overloaded operator, redefining its semantics. Kotlin has predefined mappings between function names and operators, called conventions. The current convention is for iterator() + next() + hasNext() and for loops.
The for loop is defined in Kotlin as “iterating over the elements provided by the iterator” and is used with the in reserved word:
public inline fun <T> Sequence<T>.forEach(action: (T) - >Unit): Unit {
for (element in this) action(element)
}
Copy the code
Sequence has an extension method, forEach(), to simplify the traversal syntax, and internally “for + in” is used to traverse all the elements in the Sequence.
This is why reader.foreachline () is a simple syntax for traversing all lines in a file.
public fun Reader.forEachLine(action: (String) - >Unit): Unit =
useLines { it.forEach(action) }
Copy the code
Instances of Sequence usage can click Kotlin base | literal-minded Kotlin set operations.
The semantics of LineSequence are that each element in the Sequence is a line in the file, and it implements the iterator() interface internally, constructing an iterator instance:
// Line sequence: Wraps a Line sequence around a BufferedReader
private class LinesSequence(private val reader: BufferedReader) : Sequence<String> {
override public fun iterator(a): Iterator<String> {
// Build iterators
return object : Iterator<String> {
private var nextValue: String? = null // The next element value
private var done = false // Whether the iteration is over
// Determine if there is a next element in the iterator, and get the next element to store in the nextValue
override public fun hasNext(a): Boolean {
if (nextValue == null && !done) {
// The next element is a line in the file
nextValue = reader.readLine()
if (nextValue == null) done = true
}
returnnextValue ! =null
}
// Get the next element in the iterator
override public fun next(a): String {
if(! hasNext()) {throw NoSuchElementException()
}
val answer = nextValue
nextValue = null
returnanswer!! }}}}Copy the code
The iterator inside LineSequence takes the contents of a line in the file in hasNext() and stores them in nextValue, completing the transformation of the contents of each line in the file into an element in the Sequence.
When traversed over a Sequence, the contents of each line in the file appear one by one in the iteration. LineSequence doesn’t hold the contents of every line in the file. It just defines how to get the contents of the next line in the file, and all the contents appear one by one until traversal.
A one-sentence summary of Kotlin’s algorithm for reading the contents of a file line by line: wrap the file with a BufferReader, then wrap the buffer with a sequence of LineSequence. Sequence iteration behavior is defined as reading the contents of a line in the file. As the sequence is traversed, the contents of the file are added to the list line by line.
conclusion
Top-level functions, higher-order functions, default parameters, named parameters, extension methods, generics, overloaded operators, Kotlin uses these syntactic features to hide the complexity of implementing common business functions and internally layer complexity.
Layering is an idiomatic way to reduce complexity, not only by dispersing complexity so that only a limited amount of complexity is faced at any one time, but also by giving each layer a good name to summarize the semantics of the layer. In addition, it helps to locate problems (narrow them down) and increase code reusability (each layer is reused separately).
Can you follow this hierarchical approach and think before you write code, is it too complex? What language features can be used to layer complexity with reasonable abstractions? To avoid complexity being spread out at one level.
Recommended reading
- Kotlin base | entrusted and its application
- Kotlin basic grammar | refused to noise
- Kotlin advanced | not variant, covariant and inverter
- Kotlin combat | after a year, with Kotlin refactoring a custom controls
- Kotlin combat | kill shape with syntactic sugar XML file
- Kotlin base | literal-minded Kotlin set operations
- Kotlin source | magic weapon to reduce the complexity of code
- Why Kotlin coroutines | CoroutineContext designed indexed set? (a)
- Kotlin advanced | the use of asynchronous data stream Flow scenarios