Краткий обзор Kotlin и сравнение с C#
Эта статья представляет собой заметки на коленке и является скорее кратким обзором Kotlin, включая небольшое сравнение с языком С# с точки зрения синтаксиса. Это мое мнение и мои размышления по поводу этого сравнительно молодого языка в мире Java-платформы, который на мой взгляд имеет хорошие шансы добиться успеха.
Kotlin представляет собой статически типизированный объектно-ориентированный язык программирования, компилируемый для платформ Java (еще и JavaScript). Разрабатывается с 2010 года компанией JetBrains. Релиз при этом состоялся не так давно. Авторы ставили целью создать язык более лаконичный и типобезопасный, чем Java, и более простой, чем Scala. Следствием упрощения по сравнению со Scala стали также более быстрая компиляция и лучшая поддержка языка в IDE. Помимо всего прочего, когда компания объявила о разработке данного языка, на нее обрушился шквал критики по поводу того, что лучше бы разработчики довели до ума плагин для Scala (у которой как я понимаю, до сих пор нет нормальной IDE). Однако, для компании язык программирования является достаточно важным инструментом, а разработчики Java не совсем спешат внедрять в язык новую функциональность. И дело даже не в том, что этого не хотят, а из-за того, что слишком много кода написано и слишком много систем работает на этой платформе. И вот приходится тянуть обратную совместимость за собой как балласт. И даже если в последней, 8 версии языка и добавили новые фичи (как лямбда-выражения, например), то мир Enterprise не кинулся обновлять JVM, что заставляет программистов сидеть на той версии, которая стоит у заказчика. Как показывает опыт, некоторые заказные предприятия и фирмы не так давно обновили свои машины только до 7 версии, а заставлять обновлять несколько сотен машин в системе до 8 версии будет слишком не удобно да и дорого для компании заказчика. С моей точки зрения, такая латентность языка в развитии характеризует его как достаточно развитый и мощный инструмент, что может дать представление о том, как часто он используется. Однако, по сравнению с другими языками Java иногда кажется многословной, но это мое мнение как человека, который достаточно программировал на C# и использовал к примеру тот же LINQ, лямбда-выражения и другие плюшки синтаксического сахара, которые делают код компактнее.
Поэтому люди в JetBrains решили сделать язык, который при полной совместимости с Java, предоставит дополнительные возможности, упрощающие повседневную работу программиста и повышающие продуктивность.
Знакомство…
Столкнулся я с ним случайно. Программируя на Java, я скучал по плюшкам из C# и хотелось бы как-то угодить и себе и соответствовать требованиям заказчика. Просмотрев документацию по Kotlin, я понял, что это то, что мне необходимо. Документация в 150 страниц читается достаточно легко, язык прост в изучении и достаточно лаконичен. Однако, мне больше всего понравилось то, что он имеет достаточно общего с C# и работа с языком становится еще приятней. Все-таки забывать .NET не хочется.
Вкусности…
Работа с классами
Ну, а теперь перейдем к самому интересному и рассмотрим некоторые особенности языка и что мне в нем нравится.
Как объявить класс в Kotlin:
class Man {
var name: String //var - для изменяемых переменных, val - для неизменяемых
var age: Int
constructor(name: String, age: Int) {
this.name = name
this.age = age
}
}
Почти ничего необычного, за исключением того, что конструктор помечен ключевым словом constructor. На самом деле — это вторичный конструктор с точки зрения Kotlin (а), а первичный или основной конструктор является частью заголовка класса:
class Man constructor(var name: String, var age: Int)
//или еще можно без ключевого слова
class Man (var name: String, var age: Int)
Точной такой же синтаксис эквивалентен коду, что был описан ранее. Переменные name и age также присутствуют в классе и были соответственно созданы в первичном конструкторе при помощи var (достаточно интересная особенность). С первого взгляда непривычно, но через некоторое время понимаешь, что очень даже удобно. Но основной конструктор не может содержать любой код, поэтому есть блок инициализации (init), который вызывается каждый раз при создании объекта:
class Man (var name: String, var age: Int){
init {
//какие-то операции
}
}
Интересно на мой взгляд. Можно также сделать цепочку конструкторов:
class Man (var name: String){
var name: String? = null //типы, поддерживающие null, объявляются так и это относится ко всем типам,а не только к значимым, как в C#
var age: Int = 0 //здесь необходима явная инициализация, так как это свойство, getter и setter использованы по умолчанию
constructor(name: String, age: Int) : this(name) {
this.age = age
}
}
Интересно реализованы здесь свойства и полный синтаксис для объявления:
var : [= ]
[]
[]
Инициализатор, getter и setter необязательны, если описывать класс, как было показано в первом примере. Если же переменную описывать как val, то setter соответственно запрещен. Как описывать свойства:
class Man {
var name: String
get() {
return "Name man: $field" //field - представляет собой поле, к которому нужно получить доступ. Если getter определен под объявлением переменной, field соответственно относится к этой переменной
}
private set(value) { //изменить переменную вне класса соответственно не получится
field = value
}
var age: Int
constructor(name: String, age: Int) {
this.name = name
this.age = age
}
}
Data Classes
Представляют интерес Data Classes. Данные классы используются для хранения данных и больше ничего не делают. Компилятор автоматически выводит члены из всех свойств, заявленных в основном конструкторе:
- equals ()/hashCode ()
- метод toString () формы Man («Alex», 26)
- функции для соответствующих свойств в порядке их объявления (деструктурированные объявления)
- функция copy ()
Это предоставляет удобство при работе с классами подобного типа:
data class Man (var name: String, var age: Int)
fun main(args: Array) {
var man = Man("Alex", 26) //экземпляр класса создается без оператора new
println(man) //выведет Man(name=Alex, age=26)
//деструктурированные объявления
val (name, age) = man //можно и так: val name = man.component1(); val age = man.component2();
println(name) //выведет Alex
println(age) //выведет 26
//функция copy()
var man2 = man.copy() //просто скопирует объект, не ссылку
var man2 = man.copy(age = 20) //скопирует объект, но с указанными изменениями
println(man2) //Man(name=Alex, age=20)
}
На этом описание классов я бы хотел закончить и перейти к той части языка, которая является его изюминкой.
Functions and Lambdas
Функции в Kotlin объявляются при помощи ключевого слова fun и могут быть определены глобально без привязки к конкретному классу.
fun f1(x: Int): Int {
return x * 2
}
//или так
fun f1(x: Int): Int = x * 2 //это именованная функция
fun main(args: Array) {
println(f1(5)) //выведет 10
}
Функции также могут быть вызваны при помощи инфиксной нотации, когда:
- Они являются функциями-членами или функциями расширения
- Они имеют один параметр
- Они отмечены ключевым словом infix
//Определяем расширение для Int
infix fun Int.extent(x: Int): Int {
return this + x
}
//или так
infix fun Int.extent(x: Int) = this + x
fun main(args: Array) {
//вызов функции-расширения при помощи infix нотации
println(5 extent 10) //выведет 15
//что эквивалентно вызову
println(5.extent(10))
}
Также функции имеют именованные параметры и значения аргументов по умолчанию.
Можно передавать переменное число аргументов:
fun asList(vararg ts: T): List {
val result = ArrayList()
for (t in ts) // ts is an Array
result.add(t)
return result
}
fun main(args: Array) {
val list = asList(1, 2, 3) //вернет список, состоящий из этих чисел
}
Поддерживаются локальные функции (в C# 7.0 также эту функцию реализовали)
fun f1(x: Man): String {
fun isTeenager(age: Int): Boolean {
return age in 13..19
}
if (isTeenager(x.age))
return "Man teenager"
return "Man is not a teenager"
}
Функции высшего порядка и лямбда-выражения
Отдельный интерес представляет собой эта часть языка. Функциями высшего порядка обычно называют функции, которые принимают в качестве аргументов другие функции или возвращают другую функцию в качестве результата. При этом основная идея состоит в том, что функции имеют тот же статус, что другие объекты данных. Использование функций высшего порядка приводит к абстрактным и компактным программам, принимая во внимание сложность производимых ими вычислений.
Рассмотрим пример функции высшего порядка:
//Определяем функцию высшего порядка, аргумент в виде функции, которая возвращает булево значение
fun List.filter(transform: (T) -> Boolean): List {
val result = arrayListOf()
for (item in this) {
if (transform(item)) {
result.add(item)
}
}
return result
}
fun main(args: Array) {
val list = arrayListOf(1, 4, 6, 7, 9, 2, 5, 8)
val listEven = list.filter { item -> item % 2 == 0 }
listEven.forEach { item -> print(item.toString() + " ") } // вывод: 4 6 2 8
}
Подобный подход позволяет писать код в стиле LINQ:
strings.filter { it.length == 5 }.sortBy { it }.map { it.toUpperCase() }
Полный синтаксический вид лямбда-выражения выглядит следующим образом:
val sum = { x: Int, y: Int -> x + y }
При этом если оставить дополнительные аннотации, это будет выглядеть так:
val sum: (Int, Int) -> Int = { x, y -> x + y }
В круглых скобках всегда указываются параметры, которые затем передаются в тело при помощи →.
Одна вещь, которая отсутствует в синтаксисе лямбда-выражения, это возможность указать тип возвращаемого значения. В большинстве случаев это излишне, потому что возвращаемый тип может быть выведен автоматически. Однако, если его нужно явно указать, можно использовать альтернативный синтаксис: анонимная функция.
fun(x: Int, y: Int): Int = x + y
//альтернативный вариант
val listEven = list.filter(fun(item) = item % 2 == 0)
Карринг и частичное применение функции
Рассмотрим в качестве примера карринг и частичное применение функции и сравним реализацию на Kotlin и C#.
Некоторые люди иногда путают (да и я некоторое время назад) термины карринг и частичное применение функции и используют их взаимозаменяемо. И карринг и частичное применение это способы преобразования одного вида функции в другой.
Частичное применение функции
Частичное применение берет функцию с N параметрами и значение для одного из этих параметров и возвращает функцию с N-1 параметрами, такую, что, будучи вызванной, она соберет все необходимые значения (первый аргумент, переданный самой функции частичного применения, и остальные N-1 аргументы переданы возвращаемой функции). Таким образом, эти два вызова должны быть эквивалентны методу с тремя параметрами. На C# для этого будут использоваться делегаты. Конечно, они не являются полной заменой функциям высшего порядка, однако для демонстрации более, чем достаточно.
class Program
{
static Int32 SampleFunc(Int32 a, Int32 b, Int32 c)
{
return a + b + c;
}
//перегруженные версии ApplyPartial принимают аргументы и подставляют их в другие позиции в окончательном выполнении функции
static Func ApplyPartial
(Func function, T1 arg1)
{
return (b, c) => function(arg1, b, c);
}
static Func ApplyPartial
(Func function, T2 arg2)
{
return (c) => function(arg2, c);
}
static Func ApplyPartial
(Func function, T3 arg3)
{
return () => function(arg3);
}
static void Main(string[] args)
{
Func function = SampleFunc;
Func partial1 = ApplyPartial(function, 1);
Func partial2 = ApplyPartial(partial1, 2);
Func partial3 = ApplyPartial(partial2, 3);
var resp = partial3(); // эта строчка вызовет исходную функцию
Console.WriteLine(resp);
Console.ReadKey();
}
}
Обобщения заставляют метод ApplyPatrial выглядеть сложнее, чем он есть на самом деле. Отсутствие типов высшего порядка в C# означает, что необходима реализация метода для каждого делегата, который мы хотим использовать. Для этого, возможно, потребуется семейство Action.
Пример кода на Kotlin:
fun sampleFunc(a: Int, b: Int, c: Int): Int {
return a + b + c
}
fun f3(a: Int, b: Int): Int {
return sampleFunc(a, b, 3)
}
fun f2(a: Int): Int {
return f1(a, 2)
}
fun f1(): Int {
return f2(1)
}
//альтернативный вариант с использованием лямбда-выражений
val sampleFunc = { a: Int, b: Int, c: Int -> a + b + c }
val f3 = { a: Int, b: Int -> sampleFunc(a, b, 3) }
val f2 = { a: Int -> f3(a, 2) }
val f1 = { -> f2(1) }
fun main(args: Array) {
println(f1()) //выведет 6
}
В Kotlin, как в C# необходимо создавать отдельную функцию (объект) для получения функции с N-1 аргументами. Подходы у языков одинаковые, только в Kotlin это делать удобнее за счет более компактного синтаксиса.
Карринг
В то время как частичное применение преобразует функцию с N параметрами в функцию с N-1 параметрами, применяя один аргумент, карринг декомпозирует функцию на функции от одного аргумента. Мы не передаем никаких дополнительных аргументов в метод Curry, кроме преобразуемой функции:
- Curry (f) возвращает функцию f1, такую что…
- f1(a) возвращает функцию f2, такую что…
- f2(b) возвращает функцию f3, такую что…
- f3© вызывает f (a, b, c)
Реализация на C# будет выглядеть так:
class Program
{
static Int32 SampleFunc(Int32 a, Int32 b, Int32 c)
{
return a + b + c;
}
static Func>> Curry
(Func function)
{
return a => b => c => function(a, b, c);
}
static void Main(string[] args)
{
Func function = SampleFunc;
// вызов через карринг
Func>> f1 = Curry(function);
Func> f2 = f1(1);
Func f3 = f2(2);
Int32 result = f3(3);
// или соберем все вызовы вместе...
var curried = Curry(function);
result = curried(1)(2)(3);
Console.WriteLine(result); //выведет 6
Console.ReadKey();
}
}
Код на Kotlin:
fun curry(body: (a: Int, b: Int, c: Int) -> Int): (Int) -> (Int) -> (Int) -> Int {
return fun(a: Int): (Int) -> (Int) -> Int {
return fun(b: Int): (Int) -> Int {
return fun(c: Int): Int = body(a, b, c)
}
}
}
//без дополнительных аннотаций
fun curry(body: (a: Int, b: Int, c: Int) -> Int) =
fun(a: Int) = fun(b: Int) = fun(c: Int) = body(a, b, c)
fun main(args: Array) {
val f = curry { a: Int, b: Int, c: Int -> a + b + c }
val response = f(1)(1)(1)
println(response)
}
Inline function
Использование высших функций приводит к накладным расходам. Выделение памяти, на объекты функций, а также последующая очистка. Во многих случаях такого рода издержки могут быть устранены путем подстановки лямбда-выражений. Рассмотрим функцию, которая принимает в качестве параметров функцию, принимает объект блокировки и функции, получает блокировку, выполняет функции и снимает блокировку:
fun lock(lock: Lock, body: () -> T): T {
lock.lock()
try {
return body()
}
finally {
lock.unlock()
}
}
Однако при вызове происходит создание объекта. Вместо создания объекта, компилятор может вставить следующий код:
l.lock()
try {
foo()
}
finally {
l.unlock()
}
Чтобы заставить компилятор это сделать, необходимо добавить в объявлении метода модификатор inline:
inline fun lock(lock: Lock, body: () -> T): T {
// ...
}
Однако не стоит встраивать большие функции, это может сказаться на производительности. Если есть необходимость в том, чтобы происходило встраивание не всех функций, можно добавить модификатор noinline:
inline fun foo(inlined: () -> Unit, noinline notInlined: () -> Unit) {
// ...
}
Вывод…
Kotlin достаточно интересный язык, который изучать одно удовольствие. Мне нравится его компактный синтаксис и те широкие возможности, которые он предоставляет. Отдельной заслугой стоит упомянуть тот факт, что его можно использовать вместе с Java в одном проекте, что тоже достаточно интересно и дает большую гибкость при создании проекта. Этот язык позволяет быстро разработать программу и причем сделать это довольно красиво. Схожий синтаксис с тем же С# делает его в освоении еще проще, ну и приятнее. Поэтому если кому-то вдруг захочется перейти на платформу Java с платформы .NET, этот язык, возможно, оставит приятные впечатления.
P.S. интересно мнение по поводу этого языка как Java-программистов, так и C#. Стали бы Вы использовать Kotlin в своих проектах?