A Simple Kotlin Program
Up until now we have seen the basics of Kotlin: how to define functions and classes, the control flow constructs available, etc. In this chapter we are going to put all of this together to create a simple Kotlin program. This program will convert a bunch of CSV files in a bunch of JSON files.
Setting Up
We do not need any external library in this project, so we are going to create a simple Kotlin projectwith the name CSVtoJSON
. We only need to create a Kotlin file inside the src
folder and we are ready to work. We choose the name Program
for our file, but you can choose any name you want.
Inside this file we need to add an import for a Java module, since Kotlin reuse the standard Java library to access a file.
import java.io.File
Getting a List of CSV Files
Now that everything is ready, we can start working on the main function.
fun main(args: Array) {
// get a list of files in the input directory
val files = File("./input").listFiles()
// walk through the list of files
for (file in files) {
// analyze only the CSV files
if (file.path.endsWith((".csv"))) {
// get the content of the file divided by lines
val input: List = File(file.path).readLines()
// separate the header row from the rest of the content
val lines = input.takeLast(input.count() - 1)
val head: List = input.first().split(",")
[..]
}
}
}
This the first part of the function, where we collect the files in the input
directory, filter only the ones that are CSV files and read the contents of each file. Once we have the lines of each CSV files we separate the first line, that contains the header, from the rest of the content. Then we get the names of the columns from the header line.
The code is quite easy to understand. The interesting part is that we can easily mix Java classes with normal Kotlin code. In fact, parts like the class File
and the field path
are defined elsewhere in Java, while the functions endsWIth
and readLines
are Kotlin code. You can check that with IntelliJ IDEA, by trying to look at the implementation code, just like we do in the following video.
You can access Kotlin code directly, by right-clicking on a piece of Kotlin code and going to the implementation voice in the menu. Instead since Java is distributed as compiled code, you can only see after it has been decompiled.
Given that the Java code is available only through the decompilation, the first time you try to access it you would see a warning like this one.
Convert CSV data to JSON
Once that we have got the content of the CSV file, we need to transform it in the corresponding JSON data.
The following code corresponds to the [..] part in the previous listing.
var text = StringBuilder("[")
for (line in lines) {
// get the individual CSV elements; it's not perfect, but it works
val values = line.split(",")
text.appendln("{")
// walk through the elements of the CSV line
for (i in 0 until values.count()) {
// convert the element in the proper JSON string
val element = getElement(values[i].trim())
// write the element to the buffer
// pay attention to how we write head[i]
text.append("\t\"${head[i]}\": $element")
// append a comma, except for the last element
if(i != values.count() - 1)
text.appendln(",")
else
text.appendln()
}
text.append("},")
}
// remove the last comma
text.deleteCharAt(text.length-1)
// close the JSON array
text.appendln("]")
val newFile = file.path.replace(".csv",".json")
File(newFile).writeText(text.toString())
}
For each file we create a StringBuilder
variable to contain the text. The cycle to transform the format from CSV to JSON is simple:
- we loop through each line
- for each line, we create a list of elements by splitting the line for each comma we found
- we use each element of the list as a value of the JSON field, we pick as name the element of the header of the CSV file in the corresponding position
The rest of the code deals with ensuring to add the right delimiters for JSON and writing the new JSON file.
All that remains to see is the function getElement
, that we use to convert the CSV element in the proper JSON version.
fun isNumeric(text: String): Boolean =
try {
text.toDouble()
true
} catch(e: NumberFormatException) {
false
}
fun getElement(text: String) : String {
when {
// items to return as they are
text == "true" || text == "false" || text == "null" || isNumeric(text) -> return text
// strings must be returned between double quotes
else -> return "\"$text\""
}
}
We need to convert a CSV element in the corresponding JSON element: simple strings have to be put between double quotes, while numbers and special values (i.e., boolean constants and null) can be written as they are. To check whether an element is a number we create the function isNumeric
.
To convert a string into a number there is no other way that trying to do that and catching the resulting exception, if the conversion fails. Since in Kotlin try is an expression, we can use the expression syntax for the function isNumeric
. If the conversion succeeds, we know that the text is a number, so we return true otherwise we return false.
And that is pretty much our simple program.
We hope that you can see how clear and easy to use is Kotlin: it smooths the hard edges of Java and get a you a concise language that is fun to use.
Advanced Kotlin
Now we can move to the advanced parts of Kotlin, where we learn how to take advantage of its most powerful features. How to use functions at their fullest with higher-order functions and lambdas. We explain what are and how to use generic types and the powerful features around them available in Kotlin. Finally, we take a look at a few interesting niceties of Kotlin and how to create a real world Kotlin program.
Higher-order Functions
In Kotlin functions are first-class citizens: they can be stored in variables and passed around just like any other value. This makes possible to use higher-order functions: functions that accepts argument and/or return functions as a result. In particular, a lambda is a function literal: an anonymous function that is not declared but is used directly as an expression.
Basically, a lambda is a block of code that can be passed around just like any other literal (e.g., just a like a string literal "a string"
). The combination of these features allows Kotlin to support basic functional programming.
Function Types
The core of the functional support are function types: anonymous types that corresponds to the signature of a function (i.e., parameters and the return type). They can be used just like any other type.
Their syntax is a list of parameters between parentheses, followed by an arrow and the return type.
var funVar: (String) -> Unit
In this example the variable funVar can hold any function that has the corresponding signature. That is to say any function that accepts a String
as argument and returns Unit
(i.e., no value).
fun tell(text: String) {
println(text)
}
fun main(args: Array) {
var funVar: (String) -> Unit
funVar = ::tell
}
For example, you could assign directly a function using a callable reference to that particular element. The syntax is name_of_the_class::name_of_the_function
. In the previous example tell
is a top-level function so the class is absent. If you wanted to reference a functon like toDouble
of the String
class, you would use String::toDouble
.
When you define a function type you always have to explicitly indicate the return type. When declaring normal functions that return Unit
you can omit the return type, but not with function types. Also, you have to put the parentheses for the parameters, even when the function type does not accept any parameter.
// wisdom has no arguments and gives back nothing meaningful
val wisdom: () -> Unit = {
println("Life is short, but a string can be long")
}
Of course, if the compiler can infer the type correctly, you can omit the type altogether. This is true for all types, even function types. So, we could have written the previous example even in this way, because the compiler can understand that the lambda has no parameter and returns nothing.
val wisdom = {
println("Life is short, but a string can be long")
}
Lambdas
You could also directly assing a lambda to funVar. The syntax of a lambda reflects the syntax of a function type, the difference is that you have to set the name of the arguments of the lambda and you do not need to set the return type.
var funVar: (String) -> Unit = { text: String -> println(text) }
In this example we put the equivalent of the function tell
as the body of the lambda. Whatever way you use to assign a function to the variable funVar, once you do that, you can use it just like any other normal function.
// it prints "Message"
funVar("Message")
This code prints the message just like as if you called the function tell
directly.
You could also call a lambda directly, supplying the argument.
// it prints 15
println({ x: Int -> x * 3 }(5))
Here we directly call the lambda with argument 5
, so that the result of our lambda (15
) is passed as argument to the function println
.
Conventions
Lambda are so useful that Kotlin has a couple of interesting conventions to simplify their use.
The first one is the implicit parameter it.
If both these conditions are true:
- the compiler already knows the signature of the lambda, or can figure it out
- the lambda has only one argument
Then you can omit declaring the parameter of the lambda and use the implicit parameter it.
var simpleFun: (Int) -> Int = { it + 2 }
// this is equivalent to the following declaration
// var simpleFun: (Int) -> Int = {i: Int -> i + 2 }
println(simpleFun(2))
Notice that you can also declare the parameter yourself explicitly. This is a better choice if you have nested lambda.
The second convention applies only if a lambda is passed as argument of the last parameter of a function. In such cases you can write the lambda outside the parentheses. If the lambda is the only argument of the function, you can omit the parentheses altogether.
Let’s start with a function.
fun double(number: Int = 1, calculation: (Int) -> Int) : Int {
return calculation(number) * 2
}
The function **double **has as parameters an Int
and a function. The parameter Int
has a default value of 1
. The function has one parameter of type Int
and return a value of type Int
. The function double returns whatever is returned by the lambda calculation multiplied by 2.
val res_1 = double(5) {
it * 10
}
val res_2 = double {
it * 2
}
println(res_1) // it prints 100
println(res_2) // it prints 4
In the first case, we supply to double both an argument for number (i.e., 5
) and a lambda for calculation. For the second one, we just supply a lambda because we take advantage of the default argument of number. In both cases we write the lambda outside the parentheses, in the second case we can omit the parentheses altogether.
Lambda and Collections
Lambdas are very useful with collections and in fact they are the backbone of the advanced manipulation of collections.
map and filter
The basic function to manipulate collection is filter
: this functions accepts as argument a lambda and returns a new collection. The lambda is given as argument an element of the collection and returns a Boolean
. If the lambda returns true
for an element, that element is added to the new collection, otherwise is excluded.
val li = listOf(1, 2, 3, 4)
// it prints [2,4]
println(li.filter( i: Int -> i % 2 == 0 }))
In this example, the filter function returns all even numbers.
The function map
creates a new collection created by applying the lambda supplied to map to each element of the collection.
val li = listOf(1, 2, 3, 4)
// we use the it implicit parameter
println(li.map { it * 2 }) // it prints [2, 4, 6, 8]
In this example, the map function doubles each element of the collection.
data class Number(val name: String, val value: Int)
val li = listOf(Number("one", 1), Number("two", 2), Number("three", 3))
// it prints [one, two, three]
println(li.map { it.name })
You are not forced to create a new collection of the same type of the original one. You can create a new collection of any type. In this example we create a list of String
from a list of Number
.
find and groupBy
The function find
returns the first element of the collection that satisfies the condition set in the lambda. It is the same function as firstOrNull
, which is a longer but also clearer name.
val list = listOf(1, 2, 3, 4)
println(list.find({ it % 2 == 0 })) // it prints 2
val set = setOf("book", "very", "short")
println(set.find {it.length < 4}) // it prints "null"
println(set.find {it.length > 4}) // it prints "short"
In this example we use the find function on a list and a set. For the list, it returns only the first element that satisfy the condition, even though there is more than one. For the set, it returns null a first time, and the element that satisfies the condition, the second time.
Given that any elements after the first that satisfy the condition is ignored it is better to use as condition of find an element that identifies only one element. It is not necessary, but it is better for clarity. If you are just interested in any element that satisfies the condition it is more readable to use the function firstOrNull
directly.
val list = listOf(1, 2, 3, 4)
// equivalent to the previous example, but clearer
println(list.firstOrNull({ it % 2 == 0 })) // it prints 2
The function groupBy
allows to divide a collection in more groups according to the condition indicated.
data class Number(val name: String, val value: Int)
val list = listOf(Number("one", 3), Number("two", 3), Number("three", 5))
// it prints
// {3=[Number(name=one, value=3), Number(name=two, value=3)],
// 5=[Number(name=three, value=5)]}
println(list.groupBy { it.value })
val set = setOf(1, 2, 3, 4)
// it prints
// {false=[1, 3], true=[2, 4]}
println(set.groupBy({ it % 2 == 0 }))
In the first example we use groupBy to group the elements of a list according to one property of the element. In the second one we divide the elements of a set depending on whether they are odd or even numbers.
The condition of groupBy can be any complex function. In theory you could use whatever condition you want. The following code is pointless, but valid code.
val rand = Random()
val set = setOf(1, 2, 3, 4)
// it may print {2=[1, 3, 4], 1=[2]}
println(set.groupBy { rand.nextInt(2) + 1 })
fold
The method fold
collapses a collection to a unique value, using the provided lambda and a starting value.
val list = listOf(5, 10)
// it prints 15
println(list.fold(0, { start, element -> start + element }))
// it prints 0
println(list.fold(15, { start, element -> start - element }))
In the previous example, the first time we start with 0 and added all elements until the end. In the second case, with start with 15 and subtracted alle elements. Basically, the provided value is used as argument for start in the first cycle, then start becomes the value returned by the previous cycle.
So, in the first case the function behaves like this:
- start = 0, element = 5 -> result 5
- start = 5, element = 10 -> result 15
It is natural to use the function with numeric collections, but you are not restricted to using them.
val reading = setOf("a", "short", "book")
// it prints "Siddharta is a short book"
println(reading.fold("Siddharta is ", { start, element -> start + "$element "}))
flatten and flatMap
The function flatten
creates one collection from a supplied list of collections.
val list = listOf(listOf(1,2), listOf(3,4))
// it prints [1,2,3,4]
println(list.flatten())
Instead flatMap
use the provided lambda to map each element of the initial collection to a new collection, then it merges all the collections into one collection.
data class Number(val name: String, val value: Int)
val list = listOf(Number("one", 3), Number("two", 3), Number("three", 5))
// it prints [3, 3, 5]
println(list.flatMap { listOf(it.value) } )
In this example, we create one list with all the values of the property value
of each element of type Number
. These are the steps to arrive to this result:
- each element is mapped to a new collection, with these three new lists
- listOf(3)
- listOf(3)
- listOf(5)
- then the these three lists are merged in one list, listOf(3,3,5)
Notice that the initial collection does not affect the kind of collection that is returned. That is to say, even if you start with a set you end up with a generic collection.
val set = setOf(Number("one", 3), Number("two", 3), Number("three", 5))
// it prints [3, 3, 5]
println(set.flatMap { setOf(it.value) } )
Luckily this, and many other problems, can be easily solved thanks to the fact that you can concatenate lists and functions.
val set = setOf(Number("one", 3), Number("two", 3), Number("three", 5))
// it prints [3, 5]
println(set.flatMap { setOf(it.value) }.toSet() )
You can combine the function we have just seen, and many others, as you wish.
val numbers = listOf("one", "two", "three", "four", "five")
// it prints [4, 5]
println(numbers.filter { it.length > 3 }.sortedBy{ it }.map { it.length }.toSet())
In this example:
- we filter all element which have length of at list 3
- then we sort the elements
- then we create a new collection by mapping each element to its length
- finally we create a set, which has only unique elements
Generic Types
Why We Need Generic Types
If you already know what generic types are, you can skip this introduction.
A language with static typing, like Kotlin, is safer to use than one with dynamic typing, such as JavaScript. That is because it eliminates a whole class of bugs related to getting the type of a variable wrong.
For example, in JavaScript you may think that a certain variable is a string, so you try to access its field length. However, it is actually a number, because you mixed the type of the element returned from a function. So, all you get is an exception, at runtime. With a language like Kotlin, these errors are caught at compilation time.
The downside is that development takes a bit longer and it is a bit harder. Imagine that you are trying to sum two elements. With a language with dynamic typing that is easy: just check if the two elements are numbers and then sum them.
With a language with static typing this is harder: you cannot sum elements of different types, even if you can do the sum, you do not really know what type the function returns. For example, if you sum two Int
the type returned must be Int
, but if the elements are Double
the type returned must be of that type.
On the other hand, these kinds of constraints are exactly the reason because static typing is safer to use, so we do not want to renounce to them.
There is a solution that can save both safety and power: generic types.
How to Define Generic Types in Kotlin
Generic types are types that can be specified when you create of an object, instead of when you define a class.
class Generic(t: T) {
var value = t
}
val generic = Generic(5)
// generic types can be inferred, just like normal types
val doubleGeneric = Generic(5.5)
When you use a generic types you are saying to the compiler that some variables (one or more) will be of a type that will be specified later. Even if you do not know which one exactly is yet, the compiler can perform the usual checks to ensure that the rules for type compatibility are respected. For example, if you sum two elements you can say that the elements wll be both of type T
, so the type returned by the sum function will be also of type T
.
You can also use them in functions, you have to put the generic type name (es. T
) after the keyword fun
and before the name of the function.
fun genericFunction(obj: T): List
{
return listOf(obj)
}
val item = genericFunction("text")
Constraining a Generic Type
These are the basics, but Kotlin does not stop there. It has a quite sophisticated support for defining constraints and conditions on generic types. The end results is that generic types are powerful but also complex.
You can limit a generic type to a specific class or one of its subclasses. You can do it just by specifying the class after the name of the generic type.
// the type Number is predefined by Kotlin
fun double(value: T): Double {
// accepts T of type Number or its subclasses
return value.toDouble() * 2.0
}
// T is of type double
val num_1 : Double = double(5.5)
// T is of type int
val num_2 : Double = double(5)
// T is of type float
val num_3 : Double = double(5.5f)
// it does not compile
val error = double("Nope")
In this example, the num_n
values are all of type Double
, even though they functions accepted T
of different types. The last line does not compile because it contains an error: String
is not a subclass of Number
.
Variance
The concept of variance refers to the relation between generic types with argument types that are related. For instance, given that Double
is a subclass of Number
, is List
a subclass of List
? At first glance you may think that the answer should be obvious, but this is not the case. Let’s see why.
Imagine that we have a function that read elements from an immutable List.
fun read(list: List) {
println(list.last())
}
What will happen if we tried using it with lists with different type arguments?
val doubles: MutableList = mutableListOf(5.5, 4.2)
val ints: MutableList = mutableListOf(5, 4)
val numbers: MutableList = mutableListOf(3, 2.3)
read(doubles) // it prints 4.2
read(ints) // it prints 4
read(numbers) // it prints 2.3
There is no issue at all, everything works fine.
However, things change if we have a function that adds elements to a list.
fun add(list: MutableList) {
list.add(33)
}
What will happen if we tried using it with lists with different type arguments? The compiler will stop us most of the times.
val doubles: MutableList = mutableListOf(5.5, 4.2)
val ints: MutableList = mutableListOf(5, 4)
val numbers: MutableList = mutableListOf(3, 2.3)
add(doubles) // this is an error
add(ints) // this is an error
add(numbers) // this works fine
We cannot safely add elements to a list which has a type argument of a subtype because we do not know the actual type of the elements of the list. In short, the issue is that we could have a list of Double
and we cannot add Int
to this list, or vice versa. That is because this would break type safety and we would have a list with argument of types different from the one we expect.
So, it is generally safe to read elements from a List with elements of a subtype, but not add to it. This kind of complex situations can rise with all generic classes. Let’s see what can happen.
Covariance
A generic class is covariant if it is a generic class for which it is true that if A is a subclass of B, then Generic is a subclass of Generic. That is to say the subtype is preserved.
For example, if a List of Int is a subtype of a List of Number then List is covariant.
To define a covariant class you use the modifier out
before the name of the generic type.
class Covariant {
fun create() : T
}
Declaring a class as covariant allows to pass to a function arguments of a certain generic type, or any compatible subtype, and to return argument of a compatible subytpe.
open class Animal
// T is covariant and also constrained to be a subtype of Animal
class Group { /* .. */}
fun feed(animals: Group) { // <- notice that is Group
/* .. */
}
class Cat : Animal() { /* .. */ }
fun feedCats(cats: Group) { // <- it is Group
feed(cats) // <- if T wasn't covariant this call would be invalid
// that's because Group would not be a subtype of Group
}
This example contains three classes: Animal
and Cat
are normal classes, while Group
is a generic class. The covariance is declared for Group
and that is what allows its use in functions that use this type.
We can pass an object of type Group
to the function feed()
only because we have declared T to be covariant. If you omitted the out modifier you would receive an error message about an incompatible type:
Type mismatch: inferred type is Group
but Group was expected
Covariance Is Not Free
Having covariance is useful, but it is not a free lunch. It is a promise that you make that variables of the generic type will be used only in certain ways that guarantees to respect covariance.
In practical terms it means that if you want to declare a type parameter T
as covariant you can only use it in *out position. *So, all the methods that use it, inside the generic class, can only produce elements of that type and not consume it. Of course, this restriction only applies to methods inside that specific generic class. You can use the whole generic class normally as argument of other functions.
For example, the following is valid code.
class Group {
fun buy() : T {
return Animal() as T
}
}
However, this is not.
class Group {
// error: you cannot do it if T is covariant
fun sell(animal: T) { /* .. */ }
}
If you try to use a type declared as coviarant you will see this error message:
Type parameter T is declared as ‘out’ but occurs in ‘in’ position in type T
Contravariance
A generic class is contravariant if it is a generic class for which it is true that if A is a subclass of B, then Generic is a subclass of Generic. That is to say the subtype is reversed.
This image shows the subtype relations between different classes.
To define a contravariant class you use the modifier it
before the name of the generic type.
class Contravariant {
fun read(e: T)
}
As you can imagine, to declare a type parameter as contravariant you need to respect the opposite restriction than the one for a covariant class. You can only use the type in in position for the methods of the class.
Let’s see an example of a contravariant class:
open class Animal
class Dog : Animal()
// T is contravariant and constrained to be a subclass of Animal
class Pack {
fun sell(animal: T) { // <- notice that is in position
/* .. */
}
}
fun sellDog(dog: Dog) : Pack {
return Pack() // <- if T wasn't contravariant this call would be invalid
// that's because Pack would not be a subtype of Pack
}
This example contains three classes: Animal
and Dog
are normal classes, while Pack
is a generic class. The contravariance is declared for Pack
and that is what allows its use in functions that use this type.
We can return object of type Pack
in the function sellDog()
only because we have declared T to be contravariant. If you omitted the in
modifier you would receive an error message about an incompatible type:
Type mismatch: inferred type is Pack
but Pack was expected
A Few Niceties
In this chapter we cover a few nice features of Kotlin.
Extension Methods
You can define methods that seem to extend existing classes.
fun String.bePolite() : String {
return "${this}, please"
}
var request = "Pass the salt"
// it prints "Pass the salt, please"
println(request.bePolite())
However, this is just syntactic sugar: these methods do not modify the original class and cannot access private members.
Alternatives to Static
Kotlin has much to offer, but it lacks one thing: the static
keyword. In Java and other languages is used for a few different reasons, each of these has a Kotlin alternative:
- to create utility functions
- Kotlin has extensions methods, that allows to easily extend a class
- Kotlin allows to use first-level functions, outside of a class
- global fields or methods for all objects of a class
- Kotlin has the keyword
object
- Kotlin has the keyword
We have already seen the first two solutions, so let’s how the keyword object solve the need for static fields for a class.
A Singleton is Better than Static
Kotlin allows to define an object
simply by using the keyword object instead of class
.
object Numbers {
var allNumbers = mutableListOf(1,2,3,4)
fun sumNumbers() : Int { /* .. */ }
fun addNumber(number: Int) { /* .. */ }
}
In practical terms, using object
is equivalent to using the Singleton pattern: there is only one instance of the class. In this example this means that Numbers can be used as an instance of the class Numbers
.
fun main(args: Array) {
println(Numbers.sumNumbers()) // it prints 10
Numbers.addNumber(5)
println(Numbers.sumNumbers()) // it prints 15
}
If you need something to store information about the relationship between different instances of a class or to access the private members of all instances of a class you can use a companion object
.
class User private constructor(val name: String) {
// the constructor of the class is private
companion object {
// but the companion object can access it
fun newUser(nickname: String) = User(nickname)
}
}
// you can access the companion object this way
val mark = User.newUser("Mark")
Companion objects are ideals for the factory method pattern or as alternative to static fields.
Infix Notation
Using the infix
keyword on the declaration of a function you can use it with infix notation: there is no need to use parentheses for the parameter. This notation can only be used with functions that accept one argument.
infix fun String.screams(text: String): String
{
return "$this says aloud $text"
}
val mike = "Michael"
val strongHello = mike screams "hello"
// it prints "Michael says aloud hello"
println(strongHello)
This notation makes the code more readable and gives the impression that you can create custom keywords. It is the reason because you can use ranges like the following one, using until
:
for (e in 1 until 3)
print("$e ")
It is also a nice example of the power of the language itself. Kotlin is quite light as a language and a lot of its features are in the standard library, which means that even you can create powerful and elegant code with ease.
Destructuring Declarations
Destructuring declarations are a feature of Kotlin that allows to decompose an object in its constituent parts
val (x, y) = a_point
The variables defined in this way (x and y in this example) are normal variables. The magic is in the class of the object (a_point in this example).
It seems that it is possible to return more than one result from a function, but this is not true. This is just syntactic sugar. The compiler transforms the previous call in the following code.
val x = a_point.component1()
val y = a_point.component2()
This is another example of the power of intelligent conventions in Kotlin. To make destructuring declarations work for your classes you need to define componentN functions preceded by the keyword operator
.
class point(val x: Int, val y: Int) {
operator fun component1() = x
operator fun component2() = y
}
They are quite useful with Map
collections, for which they are already defined the proper componentN functions.
for ((key, value) in a_map) {
// ..
}
They can be used with all collections, to get their elements.
val (a, b, c) = listOf(1, 2, 3)
println("a=$a, b=$b, c=$c")
If you are not interested in a certain element, you can use _ to ignore it.
val (_, y) = a_point
The necessary componentN functions are defined automatically for data classes. This favors the use of a common Kotlin pattern: defining a data class to be used to return values from a function.
For example, you can create a data class that contains both the status of the operation (i.e., success/failure) and the result of the operation.
data class Result(val result: Int, val status: Status)
fun operation(): Result {
/* .. */
return Result (result, status)
}
// now you can use it like this
val (result, status) = operation()
Using Kotlin for a Real Project
We have seen a complete picture of Kotlin: everything from basics to understanding lambdas. In this section we are going to put all of this together to create a simple, but realistic Kotlin program. This program has a graphical UI that allows the user to calculate some metrics of a text: its readability and the time it takes to read it.
Setting Up
We are going to need an external library for the UI of this project, so we are going to setup a Gradle project. Once you have followed the instructions you will have a project that will look like the following. We named our project textAnalisys
and created thre Kotlin files:
-
AnalysisApp
which contains the UI -
Program
for the main application code -
TextMetrics
for the library methods to calculate the metrics
The first thing that you have to do is open the build.gradle
file and add the TornadoFX dependency. This is a Kotlin library that simplifies using the JavaFX framework, the default framework to create UI for desktop apps.
[..]
repositories {
mavenCentral()
}
dependencies {
compile "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
compile "no.tornado:tornadofx:1.7.15"
}
compileKotlin {
kotlinOptions.jvmTarget = "1.8"
}
compileTestKotlin {
kotlinOptions.jvmTarget = "1.8"
}
Most of the text will be already there, we just added the depedency TornadoFX inside the dependencies
block.
Once you have added the library you should build the gradle, by right clicking on the file build.gradle
. If everything works correctly you will see the TornadoFX library in your project inside the External Libraries section.
Calculating the Text Metrics
Now that everything is ready, let’s see the TextMetrics.kt
file, which contains the code that calculate metrics.
The file contains an object
TextMetrics
with two public functions: one to calculate the time needed to read the text, the other one to calculate how hard it is to read the text. We create an object instead of a class, because the metrics are independents and there is no need to store information about the text.
Time to Read a Text
object TextMetrics {
// for the theory behind this calculation
// see http://iovs.arvojournals.org/article.aspx?articleid=2166061
fun timeToRead(text: String) : Double =
text.count { it.isLetterOrDigit() }.toDouble() / 987
To calculate the time needed to read the text we simply count the numbers of meaningful characters (i.e., we exclude punctuation and whitespace) and divide the result by 987
. The number comes from a research that studided this method to calculate the time needed to read a text. The number is valid for texts written in the English language.
This code is very concise thank to two Kotlin features: the expression syntax to define function and the simplified syntax for passing lambdas. The function String::count
accepts a lambda that is applied to each character of the string, so all we need to do is to put a check that matches only meaningful characters.
Readability of a Text
// Coleman–Liau index
fun readability(text: String) : Double {
val words = calculateWords(text).toDouble()
val sentences = calculateSentences(text).toDouble()
val letters = text.count { it.isLetterOrDigit() }.toDouble()
// average number of letters per 100 words
val l = letters / words * 100
// average number of sentences per 100 words
val s = sentences / words * 100
val grade = 0.0588 * l - 0.296 * s - 15.8
return if(grade > 0) grade else 0.0
}
To calculate the difficulty of the text we use the Coleman-Liau index, one of the readibility tests out there. We choose this test because it works on individual letters and words, which are easy to calculate. Some other tests instead use syllables or rely on a database of simple words which are harder to calculate.
Basically, this test looks up how long are the words and how long are the sentences. The longer the sentences are and the longer the words are the harder is the text.
This test outputs a number that corresponds to the years of schooling necessary to understand the text. This test works only for documents written in the English Language.
The code itself is easy to understand, the only thing we need to ensure is that the grade returned is higher than 0. It could be less if the text is particularly short.
Calculating the Number of Senteces
private fun calculateSentences(text: String) : Int {
var index = 0
var sentences = 0
while(index < text.length) {
// we find the next full stop
index = text.indexOf('.', index)
// if there are no periods, we end the cycle
if (index == -1) index = text.length
when {
// if we have reached the end, we add a sentence
// this ensures that there is at least 1 sentence
index + 1 >= text.length -> sentences++
// we need to check that we are not at the end of the text
index + 1 < text.length
// and that the period is not part of an acronym (e.g. S.M.A.R.T.)
&& index > 2
&& !text[index - 2].isWhitespace() && text[index - 2] != '.'
// and that after the period there is a space
// (i.e., it is not a number, like 4.5)
&& text[index + 1].isWhitespace()
-> sentences++
}
index++
}
return sentences
}
Calculating the number of sentences, in English, it is not hard, but requires a bit of attention. Basically, we need to find all periods and check that they are not part of either an acronym or a number with a fractional part. Since each text contain at least one sentence, we automatically add one when we reach the end of the text given as input.
Calculating the Number of Words
private fun calculateWords(text:String) : Int {
var words = 1
var index = 1
while(index < text.length) {
if(text[index].isWhitespace()) {
words++
while(index + 1 < text.length && text[index + 1].isWhitespace()) index++
}
index++
}
return words
}
} // end of the object TextMetrics
To calculate the numbers of words is even simpler: we just need to find the whitespace and count it. The only thing that we have to check is to not count a series of spaces as more than one word. To avoid this error, when we find a space we keep advancing until we find the next not-space character.
The Graphical Interface
The library that we use for the graphical interface is TornadoFX. This library uses the MVC pattern: the model stores the business logic; the view takes care of showing the information; the controller glue the two of them and ensure that everything works correctly.
All the code for the Tornado application is inside the file AnalysisApp.kt
.
The Controller
Let’s start with seeing the controller.
import javafx.geometry.Pos
import tornadofx.*
import javafx.scene.text.Font
class MainController(): Controller() {
fun getReadability(text: String) = when(TextMetrics.readability((text))) {
in 0..6 -> "Easy"
in 7..12 -> "Medium"
else -> "Hard"
}
fun getTimeToRead(text: String): Int {
val minutes = TextMetrics.timeToRead(text).toInt()
return if (minutes > 0) minutes else 1
}
}
The controller provides two functions that we use to convert the raw information provided by the TextMetrics object in a more readable form. For the readability, we translate the number relative to a grade in a simpler textual scale. This is necessary because unless you are still in school probably you do not remember what grades means. Do you remember to which grade corresponds grade 10? So, we create a simple conversion:
- if it is less than high school it is easy
- high school is medium
- everything post high-school is hard
We also simplify the time to read seen by the user: we round up the time to the nearest minute. That’s because the calculation cannot really be that precise. The scientific research behind the calculation does not really allows such granularity. Furthermore, there are factors beyond our control that could skew the number. For instance, the real time depend on the actual reading speed of the user. So, if we gave a precise number it could be misleading. The round up number instead is generally correct, or at the very least better represents the imprecise nature of the calculation.
The View
In the same file we put the view.
class MainView: View() {
val controller: MainController by inject()
var timeToRead = text("")
var readability = text("")
var textarea = textarea("")
This first part is interesting for one reason: the way we initialize the property controller. We do it with the delegation pattern, using the kewyord by
followed by a delegate class. A delegate class is a class that follows a specific format and can be used when you need to perform complex operations to initialize a property. In this case, the [inject](https://edvin.gitbooks.io/tornadofx-guide/content/part1/3.%20Components.html#using-inject-and-embedding-views)
class is provided by TornadoFX and it finds (or creates) for you an instance of the class specified.
This pattern is also useful for lazy initialization: imagine that you have to initialize a property, but initialization is costly or is dependent on something else. Traditionally you would have to initialize the property to null and then set the proper value later. Instead with Kotlin you can use the standard delegate lazy
. This delegate accepts a lambda: the first time you access the property the lambda is executed and the value returned is stored, the next time it will simply return the stored value.
The rest of the code contains properties to store the elements of the UI that we are going to see now.
The Root Element
override val root = vbox {
prefWidth = 600.0
prefHeight = 480.0
alignment = Pos.CENTER
text("Text Analysis") {
font = Font(28.0)
vboxConstraints {
margin = insets(20.0)
}
}
textarea = textarea("Write your text here") {
selectAll()
vboxConstraints {
margin = insets(20.0)
}
}
textarea.isWrapText = true
hbox {
vboxConstraints {
alignment = Pos.BASELINE_CENTER
marginBottom = 20.0
}
label("Time to Read") {
hboxConstraints {
marginLeftRight(20.0)
}
}
timeToRead = text("No text submitted")
label("Readability") {
hboxConstraints {
marginLeftRight(20.0)
}
}
readability = text("No text submitted")
}
The root property is a requirement for a TornadoFX app, it contains the content of the view. In our program we assign its value using a Type-Safe Builder. It is a feature of Kotlin that allows to create easily with a beautiful DSL-like interface things like UI or data formats files. Basicaly anything that has a complex hierarchical structure. Everybody can create a type-safe builder in Kotlin, but they are a bit complex to design, so we did not have the chance to see before. However, as you can see they are very easy to use. In this case, we use the one provided by TornadoFX to create the UI of our app.
Without a type-safe builder you would be forced to use configuration files or an awkward series of function calls. Instead with a type-safe builder is you can create quickly what you need, and the end result is easy to understand at a glance.
Our view consists of a:
- a vertical box (the initial
vbox
) the contains- a title (the first
text
) - a box that will contain the text inputted by the user (
textarea
), which is also saved in the property textarea - a horizontal box (
hbox
) that contains- two pairs of a label and a simple text. The two texts are stored in the properties readability and timeToRead
- a title (the first
The code itself is quite easy (thanks to lambdas and type-safe builders), there are only a few terms to understand.
A vertical box stacks the elements vertically, while an horizontal box stacks them horizontally. The hboxConstraints
and vboxConstraints
contains restrictions on the layouts of the corresponding elements.
The selectAll
functions ensures that the default text (i.e., Write you text here
) is pre-selected. This allow the user to easily delete it with one click or press of the delete button.
The only element that remains is the button that is used by the user to start the analysis of the text. The following code is still inside the initial vbox we have just seen, that is assigned to the property root.
button("Analyze Text") {
action {
if(textarea.text.isNotEmpty()) {
readability.text = controller.getReadability(textarea.text)
timeToRead.text = "${controller.getTimeToRead(textarea.text)} minutes"
}
}
}
} <-- vbox ends here
}
The button definition contains an action, a lambda, that is executed when the user clicks the button itself. The action gathers the text in the textarea and calls the functions in the controller, then it assigns the results to the proper properties (i.e., the text
elements inside the hbox
). If there is no text the action does not change anything.
The Main Program
The main program is contained inside the Program.kt
file.
import javafx.application.Application
import tornadofx.App
class AnalysisApp: App(MainView::class)
fun main(args: Array) {
Application.launch(AnalysisApp::class.java, *args)
}
The file is very short because we just need to do two things:
- to create our app class, assigning to it the view
- launch the TornadoFX application with our app class
The end result is a nice graphical application.
Summary
We have learned a lot today, everything you need to know to use Kotlin in real projects. From the basics needed to define variables and functions to the more advanced features, like lambdas.
We have seen the Kotlin way, safety, conciseness and ease of use, that permeates all the language: from strings that supports interpolation to the great attention to the issues of nullability.
There is a lot of learn about Kotlin. The next steps are keeping reading this website:
- continue learning how to use Kotlin in the browser or to create native applications
- learn how to use coroutines, a Kotlin features to simplify working with asynchronous programming
- understanding how to use the Javalin web framework with Kotlin
- continue with 100+ Resources To Learn Kotlin The Right Way
And whenever you get a bit lost in Kotlin, you can find your way looking at the official reference.
The companion repository for this article is available on GitHub
原文链接:
https://superkotlin.com/kotlin-mega-tutorial/
Kotlin 开发者社区
国内第一Kotlin 开发者社区公众号,主要分享、交流 Kotlin 编程语言、Spring Boot、Android、React.js/Node.js、函数式编程、编程思想等相关主题。