Sealed Class
If you want to limit the subclasses that can be created, you can use sealed classes.
The following example does not use a sealed class:
interface Expr class Num(val value: Int) : Expr class Sum(val left: Expr, val right: Expr) : Expr fun eval(expr: Expr): Int { return when(expr){ is Sum -> eval(expr.left) + eval(expr.right) is Num -> expr.value else -> throw IllegalArgumentException("Unknown Expression") } }
This is similar to an abstract syntax tree that only supports addition. Expr represents an expression, that is, a node in the syntax tree. Meanwhile, Num represents a number node, which can only be a leaf, Sum represents an addition node, not a leaf.
Now, if we want to implement the eval function to calculate the final result of the abstract syntax tree, we find that we always need an else to finish, because Expr may have other implementation classes, which may be neither Sum nor Num, although there are no other implementation classes in the code.
Sealed classes can solve this problem.
sealed class Expr { class Num(val value: Int) : Expr() class Sum(val left: Expr, val right: Expr) : Expr() } fun eval(expr: Expr): Int { return when(expr){ is Expr.Sum -> eval(expr.left) + eval(expr.right) is Expr.Num -> expr.value } }
The sealed class indicates that this class cannot have subclasses other than Num and Sum, so the compiler can find that the code in when is impeccable and naturally does not need an additional else.
Class delegate
A set of design patterns in Java is the delegate pattern, which refers to writing a class, but it does not provide implementation. All functions will be delegated to another class for implementation, and the class will be enhanced when necessary. The proxy and dynamic proxy technologies behind Java are all implemented based on delegation. It can be said that it is a pillar of the Java world.
Kotlin supports delegation by default. Unlike Java, it either generates a lot of code with the IDE or uses other dynamic agent tools during compilation. Kotlin provides the by keyword by default.
The following class inherits from MutableCollection, but it does not store data at all, but delegates to innerSet through by.
class CountingSet<T> ( val innerSet: MutableCollection<T> = HashSet<T>() ) : MutableCollection<T> by innerSet{ var objectsAdded = 0 override fun add(element: T): Boolean { objectsAdded++ return innerSet.add(element) } override fun addAll(elements: Collection<T>): Boolean { objectsAdded += elements.size return innerSet.addAll(elements) } } fun main(){ val set = CountingSet<Int>() set.add(10) set.addAll(listOf(1,2,4,15,5,3)) set.remove(10) println(set.objectsAdded) }
Delegate classes are commonly used as constructor parameters. Delegate based IO streams are commonly used in Java. We often write this:
new BufferedInputStream(new FileInputStream(...));
Here, BufferedInputStream does not implement the reading function of InputStream by itself, but delegates it to FileInputStream and enhances its function (by establishing a buffer).
The class we wrote above is the same. You can call CountingSet to pass in different Collection implementations. The difference is that we provide a default value.
In addition to using constructor parameters, you can also directly create a new class delegate, because sometimes we just want it to delegate the same class instead of letting users choose.
class MySet<T> () : MutableCollection<T> by HashSet<T>(){ }
Attribute delegate
One thing in jetpack composition that records the status and automatically updates the UI is var value by remember. This kind of thing that monitors data update and automatically refreshes the UI is not uncommon in today's data-driven framework. Jetpack Compose implements data monitoring through attribute delegation.
class Remember{ lateinit var name: String operator fun getValue(thisRef: Any?, property: KProperty<*>): String { // return name return name } operator fun setValue(thisRef: Any?, property: KProperty<*>,value: String) { // name = value println("${property.name} is changed!!!") name = value } } fun main(){ var name: String by Remember() name = "ASDFASDF" println(name) } /* name is changed!!! ASDFASDF */
The delegated class should implement a getValue and setValue method. The delegate's variables no longer store values, but the delegated class provides storage functions.
Next, we write a lazy loaded attribute delegate, which assigns a value to the attribute only when it is accessed for the first time
class LazyDelegate<T>(private val compute: ()->T){ var t: T? = null operator fun getValue(thisRef: Any?, property: KProperty<*>): T { if (t == null) t = compute() return t!! } operator fun setValue(thisRef: Any?, property: KProperty<*>,value: T) { t = value } } fun <T> lazy(compute: ()->T) = LazyDelegate<T>(compute) fun main(){ var name: String by lazy { "HelloWorld" } println(name) }
This time, we provide a lazy method. The lazy method will return our delegate LazyDelegate, because Kotlin officially encapsulates methods for some of its own delegates, which may be the coding specification commonly used by Kotlin community. Indeed, it looks better, and the remember in jetpack composition is actually written in this way.
Then, we also use generic and lambda expressions. Lambda is used to return a value. Generally, when lazy loading is used, this lambda expression will be a very complex and may not be commonly used operation, so if the value is not needed, lazy loading will not be executed. Generics are used to support values of all types.
Companion Object
Static factory methods are often used to construct objects in Java, because static factory methods are more suitable than constructors to deal with classes that many properties can not be provided at the time of construction. Static factory methods are more readable. Kotlin is not static at all. Instead of static, kotlin uses object and top-level function. But neither applies to static factories, which often access private members in classes.
Companion objects are used for this.
class Person private constructor( name: String?, age: Int?, address: String? ){ companion object { fun fromNameAndAge(name: String, age: Int): Person = Person(name,age,null) fun fromName(name: String): Person = Person(name,null,null) fun fromNameAndAddress(name: String, address: String): Person = Person("Lucy",null,address) fun newPerson(name: String, age: Int, address: String): Person = Person(name,age,address) } } fun main(){ Person.fromNameAndAge("Lucy",12) }
The same applies to the Builder pattern, and to most places where you need to interact with private members in a class.
However, don't forget the named parameters in Kotlin. The above example could have been solved more conveniently with named parameters.
class Person constructor( name: String, age: Int? = null, address: String? = null ) fun main(){ Person(name = "Lucy", age = 12) }
Of course, associated objects can be named
class Person constructor( name: String, age: Int? = null, address: String? = null ){ companion object Loader{ fun fromJson(json: String): Person{ ... } } } fun main(){ Person.Loader.fromJson() }
Associated objects can also have extension functions. This is because associated objects such as Loader above have little relationship with the logic in the class. Separation of concerns can be realized by separating them from the outside.
class Person constructor( name: String, age: Int? = null, address: String? = null ){ companion object Loader{} } fun Person.Loader.fromJson(json: String): Person { ... } fun main(){ Person.Loader.fromJson() }
If it is a companion object without a name, it can also be used
fun Person.Companion.fromJson(json: String): Person{ ... }