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