Как себе выстрелить в ногу в Kotlin
Совсем недавно вышел релиз Kotlin, а его команда разработчиков предлагала задавать вопросы про язык. Он сейчас на слуху и, возможно, многим хочется его попробовать. Пару недель назад тимлид сделал для компании презентацию о том, что в Котлине хорошо. Одним из самых интересных вопросов был “А как в Котлине выстрелить себе в ногу?” Так получилось, что ответил на этот вопрос я.
Disclaimer: Не стоит воспринимать эту статью как “Kotlin - отстой”. Хотя я отношусь скорее к категории тех, кому и со Scala хорошо, я считаю, что язык неплохой. Все пункты спорные, но раз в год и палка стреляет. Когда-то вы себе прострелите заодно и башку, а когда-то у вас получится выстрелить только в полночь полнолуния, если вы предварительно совершите черный ритуал создания плохого кода.
Наша команда недавно закончила большой проект на Scala, сейчас делаем проект помельче на Kotlin, поэтому в спойлерах будет сравнение со Scala. Я буду считать, что Nullable в Kotlin - это эквивалент Option, хотя это совсем не так, но, скорее всего, большинство из тех, кто работал с Option, будут вместо него использовать Nullable.
1. Пост-инкремент и преинкремент как выражения #
Цитирую вопрошавшего: “Фу, это ж баян, скучно”. Столько копий сломано, миллион вопросов на собеседованиях C++… Если есть привычка, то можно было его оставить инструкцией (statement’ом). Справедливости ради, другие операторы, вроде +=, являются инструкциями. Цитирую одного из разработчиков, @abreslav:
Смотрели на юзкейсы, увидели, что поломается, решили оставить.На нет и суда нет. Разумеется, в здравом уме никто так делать не будет, но случайно - может быть.Замечу, что у нас тут не С++, и на собеседовании про инкремент спросить особо нечего. Разве что разницу между префиксным и постфиксным.
var i = 5
i = i++ + i++
println(i)
Никакого undefined behaviour, результат, очевидно, 12
11 var a = 5
a = ++a + ++a
println(a)
Тут все проще, конечно, 14
13Больше примеров
var b = 5
b = ++b + b++
println(b)
Банальная логика говорит, что ответ должен быть между 11 и 13
да, 12 var c = 5
c = c++ + ++c
println(c)
От перестановки мест слагаемых сумма не меняется
разумеется, 12 var d = 5
d = d + d++ + ++d + ++d
println(d)
var e = 5
e = ++e + ++e + e++ + e
println(e)
От перестановки мест слагаемых сумма не меняется!
Разумеется:
25
28
Чё там в Scala?
Ничего интересного, в Scala инкрементов нет. Компилятор скажет, что нет метода ++ для Int. Но если очень захотеть, его, конечно, можно определить.2. Одобренный способ #
val foo: Int? = null
val bar = foo!! + 5
Что хотели, то и получили
Exception in thread "main" kotlin.KotlinNullPointerException
В документации говорится, что так делать стоит только если вы очень хотите получить NullPointerException. Это хороший метод выстрелить себе в ногу: !! режет глаз и при первом взгляде на код все понятно. Разумеется, использование !! предполагается тогда, когда до этого вы проверили значение на null и smart cast по какой-нибудь причине не сработал. Или когда вы почему-то уверены, что там не может быть null.
Чё там в Scala?
val foo: Option[Int] = None
val bar = foo.get + 5
Что хотели, то и получили
Exception in thread "main" java.util.NoSuchElementException: None.get
3. Переопределение invoke() #
Начнем с простого: что делает этот кусок кода и какой тип у a?
class A(){...}
val a = A()
На глупый вопрос - глупый ответ
Правильно, создает новый объект типа A, вызывая конструктор по умолчанию.А здесь что будет?
class В private constructor(){...}
val b = B()
Ну, наверно, ошибка компиляции будет...
А вот и нет!
class B private constructor(){
var param = 6
constructor(a: Int): this(){
param = a
}
companion object{
operator fun invoke() = B(7)
}
}
Для класса может быть определена фабрика. А если бы она была в классе A, то там все равно вызывался бы конструктор.
Теперь вы ко всему готовы:
class С private constructor(){...}
val c = C()
Тут создается объект класса С через фабрику, определенную в объекте-компаньоне класса С.
Конечно же нет!
class C private constructor(){
...
companion object{
operator fun invoke() = A(9)
}
}
У переменной c будет тип A. Заметьте, что A и С не связаны родственными узами.
Полный код
class A(){
var param = 5
constructor(a: Int): this(){
param = a
}
companion object{
operator fun invoke()= A(10)
}
}
class B private constructor(){
var param = 6
constructor(a: Int): this(){
param = a
}
companion object{
operator fun invoke() = B(7)
}
}
class C private constructor(){
var param = 8
constructor(a: Int): this(){
param = a
}
companion object{
operator fun invoke() = A(9)
}
}
class D(){
var param = 10
private constructor(a: Int): this(){
param = a
}
companion object{
operator fun invoke(a: Int = 25) = D(a)
}
}
fun main(args: Array<String>) {
val a = A()
val b = B()
val c = C()
val d = D()
println("${a.javaClass}, ${a.param}")
println("${b.javaClass}, ${b.param}")
println("${c.javaClass}, ${c.param}")
println("${d.javaClass}, ${d.param}")
}
Результат выполнения:
class A, 5
class B, 7
class A, 9
class D, 10
К сожалению, придумать короткий пример, где у вас реально все поломается, я не смог. Но пофантазировать немного можно. Если вы вернете левый класс, как в примере с классом C, то скорее всего, компилятор вас остановит. Но если вы никуда не передаете объект, то можно сымитировать утиную типизацию, как в примере. Ничего криминального, но человек, читающий код, может сойти с ума и застрелиться, если у него не будет исходника класса. Если у вас есть наследование и функции для работы с базовым классом (Animal), а invoke() от одного наследника (Dog) вернет вам другого наследника (Duck), то тогда при проверке типов (Animal as Dog) вы можете накрякать себе беду.
Чё там в Scala?
В Scala проще - есть new, который всегда вызывает конструктор. Если не будет new
, то всегда вызывается метод apply
у компаньона (который тоже может вернуть левый тип). Разумеется, если что-то вам не доступно из-за private
, то компилятор ругнется. Все то же самое, только очевиднее.
4. lateinit #
class SlowPoke(){
lateinit var value: String
fun test(){
if (value == null){ //компилятор здесь говорит, что проверка не нужна (и правильно делает)
println("null")
return
}
if (value == "ololo")
println("ololo!")
else
println("alala!")
}
}
SlowPoke().test()
Результат предсказуем
Exception in thread "main" kotlin.UninitializedPropertyAccessException: lateinit property value has not been initialized
А как правильно?
class SlowBro(){
val value: String? = null
fun test(){
if (value == null) {
println("null")
return
}
if (value == "ololo")
println("ololo!")
else
println("alala!")
}
}
SlowBro().test()
Результат
null
Я бы сказал, что это тоже одобренный способ, но при чтении кода это неочевидно, в отличие от !!. В документации немного завуалированно говорится, что, мол, проверять не надо, если что, мы кинем тебе Exception. По идее, этот модификатор используется тогда, когда вы точно уверены, что поле будет инициализированно кем-то другим. То есть никогда. По моему опыту, все поля, которые были lateinit
, рано или поздно стали Nullable. Неплохо это поле вписалось в контроллер JavaFX приложения, где GUI грузится из FXML, но даже это “железобетонное” решение было свергнуто после того, как появился альтернативный вариант без пары кнопок. Один раз так получилось, что в SceneBuilder изменил fx:id
, а в коде забыл. В первые дни кодинга на Kotlin немного взбесило, что нельзя сделать lateinit
Int. Я могу придумать, почему так сделали, но сомневаюсь, что совсем нет способа обойти эти причины (читай: сделать костыль).
Чё там в Scala?
А там аналога lateinit
как такового и нет. По крайней мере, я не обнаружил.
5. Конструктор #
class IAmInHurry(){
val param = initSecondParam()
/*tons of code*/
val twentySecondParam = 10
/*tons of code*/
fun initSecondParam(): Int{
println("Initializing by default with $twentySecondParam")
return twentySecondParam
}
}
class IAmInHurryWithStrings(){
val param = initSecondParam()
/*tons of code*/
val twentySecondParam = "Default value of param"
/*tons of code*/
fun initSecondParam(): String{
println("Initializing by default with $twentySecondParam")
return twentySecondParam
}
}
fun main(args: Array<String>){
IAmInHurry()
IAmInHurryWithStrings()
}
Результат
Initializing by default with 0
Initializing by default with null
Все просто - к полю идет обращение до того, как оно было инициализировано. Видимо, тут стоит немного доработать компилятор. По идее, если вы пишете код хорошо, такая проблема у вас не должна возникнуть, но всякое бывает, не с потолка же я взял этот пример (коллега себе так выстрелил в ногу, случайно через цепочку методов в редко срабатывающем коде вызвал поле, которое было не инициализировано).
Чё там в Scala?
Все то же самое.
object Initializer extends App{
class IAmInHurry(){
val param = initSecondParam()
/*tons of code*/
val twentySecondParam = 10
/*tons of code*/
def initSecondParam(): Int = {
println(s"Initializing by default with $twentySecondParam")
twentySecondParam
}
}
class IAmInHurryWithStrings(){
val param = initSecondParam()
/*tons of code*/
val twentySecondParam = "Default value of param"
/*tons of code*/
def initSecondParam(): String = {
println(s"Initializing by default with $twentySecondParam")
twentySecondParam
}
}
override def main(args: Array[String]){
new IAmInHurry()
new IAmInHurryWithStrings()
}
}
Результат
Initializing by default with 0
Initializing by default with null
6. Взаимодействие с Java #
Для выстрела тут простор достаточно большой. Очевидное решение - считать все, что пришло из Java, Nullable. Но тут есть долгая и поучительная история. Как я понял, она связана в основном с шаблонами, наследованием, и цепочкой Java-Kotlin-Java. И при таких сценариях приходилось делать много костылей, чтобы заработало. Поэтому решили от идеи “все Nullable” отказаться. Но вроде как один из основных сценариев — свой код пишем на Kotlin, библиотели берем Java (как видится мне, простому крестьянину-кодеру). И при таком раскладе, лучше безопасность в большей части кода и явные костыли в небольшой части кода, которые видно, чем “красиво и удобно” + внезапные грабли в рантайме (или яма с кольями, как повезет). Но у разработчиков другое мнение:
Одна из основных причин была в том, что писать на таком языке было неудобно, а читать его — неприятно. Повсюду вопросительные и восклицательные знаки, которые не очень-то помогают из-за того, что расставляются в основном, чтобы удовлетворить компилятор, а не чтобы корректно обработать случаи, когда выражение вычисляется в null. Особенно больно в случае дженериков: например, MapСделаем небольшой класс на Java:?.
public class JavaCopy {
private String a = null;
public JavaCopy(){};
public JavaCopy(String s){
a = s;
}
public String get(){
return a;
}
}
И попробуем его вызвать из Kotlin:
fun printString(s: String) {
println(s)
}
val j1 = JavaCopy()
val j1Got = j1.get()
printString(j1Got)
Результат
Exception in thread "main" java.lang.IllegalStateException: j1Got must not be null
Тип у j1 - String!
и исключение мы получим только тогда, когда вызовем printString
. Ок, давайте явно зададим тип:
val j2 = JavaCopy("Test")
val j3 = JavaCopy(null)
val j2Got: String = j2.get()
val j3Got: String = j3.get()
printString(j2Got)
printString(j3Got)
Результат
Exception in thread "main" java.lang.IllegalStateException: j3.get() must not be null
Все логично. Когда мы явно указываем, что нам нужен NotNullable, тогда и ловим исключение. Казалось бы, указывай у всех переменных Nullable, и все будет хорошо. Но если делать так:
kotlinprintString(j2.get())
то ошибку вы можете обнаружить нескоро.
Чё там в Scala?
Никаких гарантий, NPE словить можно элементарно. Решение - оборачивать все в Option
, у которого, напомню, есть хорошее свойство, что Option(null) = None
. С другой стороны, тут нет иллюзий, что java interop безопасен.
7. infix нотация и лямбды #
Сделаем цепочку из методов и вызовем ее:
fun<R> first(func: () -> R): R{
println("calling first")
return func()
}
infix fun<R, T> R.second(func: (R) -> T): T{
println("calling second")
return func(this)
}
first {
println("calling first body")
}
second {
println("calling second body")
}
Результат
calling first
calling first body
Oops!
calling second body
Подождите-ка… тут какая-то подстава! И правда, “забыл” один метод вставить:
fun<T> second(func: () -> T): T{
println("Oops!")
return func()
}
И чтобы заработало “как надо”, нужно было написать так:
first {
println("calling first body")
} second {
println("calling second body")
}
Результат
calling first
calling first body
calling second
calling second body
Всего один перенос строки, который легко при переформатировании удалить/добавить переключает поведение. Основано на реальных событиях: была цепочка методов “сделай в background” и “потом сделай в ui треде”. И был метод “сделай в ui” с таким же именем.
Чё там в Scala?
Синтаксис немного отличается, поэтому так просто тут себе не выстрелишь:
object Infix extends App{
def first[R](func: () => R): R = {
println("calling first")
func()
}
implicit class Second[R](val value: R) extends AnyVal{
def second[T](func: (R) => T): T = {
println("calling second")
func(value)
}
}
def second[T](func: () => T): T = {
println("Oops!")
func()
}
override def main(args: Array[String]) {
first { () =>
println("calling first body")
} second { () => //<--------type mismach
println("calling second body")
}
}
}
Зато, пытаясь подогнать скаловский код хотя бы для неочевидности засчет implicit/underscore, я взорвал все вокруг.
Осторожно! Кровь, кишки и расчлененка...
object Infix2 extends App{
def first(func: (Unit) => Unit): Unit = {
println("calling first")
func()
}
implicit class Second(val value: Unit) extends AnyVal{
def second(func: (Unit) => Unit): Unit = {
println("calling second")
func(value)
}
}
def second(func: (Unit) => Unit): Unit = {
println("Oops!")
func()
}
override def main(args: Array[String]) {
first { _ =>
println("calling first body")
} second { _ =>
println("calling second body")
}
}
}
И результат:
Exception in thread "main" java.lang.VerifyError: Operand stack underflow
Exception Details:
Location:
Infix2$Second$.equals$extension(Lscala/runtime/BoxedUnit;Ljava/lang/Object;)Z @40: pop
Reason:
Attempt to pop empty stack.
Current Frame:
bci: @40
flags: { }
locals: { 'Infix2$Second$', 'scala/runtime/BoxedUnit', 'java/lang/Object', 'java/lang/Object', integer }
stack: { }
Bytecode:
0000000: 2c4e 2dc1 0033 9900 0904 3604 a700 0603
0000010: 3604 1504 9900 4d2c c700 0901 5701 a700
0000020: 102c c000 33b6 0036 57bb 0038 59bf 3a05
0000030: b200 1f57 b200 1fb2 001f 57b2 001f 3a06
0000040: 59c7 000c 5719 06c6 000e a700 0f19 06b6
0000050: 003c 9900 0704 a700 0403 9900 0704 a700
0000060: 0403 ac
Stackmap Table:
append_frame(@15,Object[#4])
append_frame(@18,Integer)
same_frame(@33)
same_locals_1_stack_item_frame(@46,Null)
full_frame(@77,{Object[#2],Object[#27],Object[#4],Object[#4],Integer,Null,Object[#27]},{Object[#27]})
same_frame(@85)
same_frame(@89)
same_locals_1_stack_item_frame(@90,Integer)
chop_frame(@97,2)
same_locals_1_stack_item_frame(@98,Integer)
at Infix2$.main(Infix.scala)
8. Перегрузка методов и it #
Это, скорее, метод подгадить другим. Представьте, что вы пишете библиотеку, и в ней есть функция
fun applier(x: String, func: (String) -> Unit){
func(x)
}
Разумеется, народ ее использует довольно прозрачным способом:
applier ("arg") {
println(it)
}
applier ("no arg") {
println("ololo")
}
Код компилируется, работает, все довольны. А потом вы добавляете метод
fun applier(x: String, func: () -> Unit){
println("not applying $x")
func()
}
И чтобы компилятор не ругался, пользователям придется везде отказаться от it
(читай: переписать кучу кода):
applier ("arg") { it -> //FIXED
println(it)
}
applier ("no arg") { -> //yes, explicit!
println("ololo")
}
Хотя, теоретически, компилятор мог бы и угадать, что если есть it
, то это лямбда с 1 входным аргументом. Думаю, что с развитием языка и компилятор поумнеет, и этот пункт — временный.
Чё там в Scala?
Без аргументов придется явно указать, что это лямбда. А при добавлении нового метода поведение не изменится.
object Its extends App{
def applier(x: String, func: (String) => Unit){
func(x)
}
def applier(x: String, func: () => Unit){
println("not applying $x")
func()
}
override def main(args: Array[String]) {
applier("arg", println(_))
applier("no arg", _ => println("ololo"))
}
}
9. Почему не стоит думать о Nullable как об Option #
Пусть у нас есть обертка для кэша:
class Cache<T>(){
val elements: MutableMap<String, T> = HashMap()
fun put(key: String, elem: T) = elements.put(key, elem)
fun get(key: String) = elements[key]
}
И простой сценарий использования:
val cache = Cache<String>()
cache.put("foo", "bar")
fun getter(key: String) {
cache.get(key)?.let {
println("Got $key from cache: $it")
} ?: println("$key is not in cache!")
}
getter("foo")
getter("baz")
Результат довольно предсказуем
Got foo from cache: bar
baz is not in cache!
Но если мы вдруг захотим к кэше хранить Nullable…
val cache = Cache<String?>()
cache.put("foo", "bar")
fun getter(key: String) {
cache.get(key)?.let {
println("Got $key from cache: $it")
} ?: println("$key is not in cache!")
}
getter("foo")
getter("baz")
cache.put("IAmNull", null)
getter("IAmNull")
То получится не очень хорошо
Got foo from cache: bar
baz is not in cache!
IAmNull is not in cache!
Зачем хранить null
? Например, чтобы показать, что результат не вычислим. Конечно, тут было бы правильнее использовать Option
или Either
, но, к сожалению, ни того, ни другого в стандартной библиотеке нет (но есть, например, в funKTionale). Более того, как раз при реализации Either
, я наступил на грабли этого пункта и предыдущего. Решить эту проблему с “двойным Nullable” можно, например, возвратом Pair
или специального data class
.
Чё там в Scala?
Никто не запретит сделать Option от Option. Надеюсь, понятно, что так все будет хорошо. Да и с null
тоже:
object doubleNull extends App{
class Cache[T]{
val elements = mutable.Map.empty[String, T]
def put(key: String, elem: T) = elements.put(key, elem)
def get(key: String) = elements.get(key)
}
override def main(args: Array[String]) {
val cache = new Cache[String]()
cache.put("foo", "bar")
def getter(key: String) {
cache.get(key) match {
case Some(value) => println(s"Got $key from cache: $value")
case None => println(s"$key is not in cache!")
}
}
getter("foo")
getter("baz")
cache.put("IAmNull", null)
getter("IAmNull")
}
Все хорошо
Got foo from cache: bar
baz is not in cache!
Got IAmNull from cache: null
10. Объявление методов #
Бонус для тех, кто раньше писал на Scala. Спонсор данного пункта - @lgorSL. Цитирую:
...
Или, например, синтаксис объявления метода:В scala:
def methodName(...) = {...}
В kotlin возможны два варианта — как в scala (со знаком
=
) и как в java (без него), но эти два способа объявления неэквивалентны друг другу и работают немного по-разному, я однажды кучу времени потратил на поиск такой “особенности” в коде.
….
Я подразумевал следующее:fun test(){ println("it works") } fun test2() = println("it works too") fun test3() = {println("surprise!")}
Чтобы вывести “surprise”, придётся написать
test3()()
. Вариант вызоваtest3()
тоже нормально компилируется, только сработает не так, как ожидалось — добавление “лишних” скобочек кардинально меняет логику программы.Из-за этих граблей переход со скалы на котлин оказался немного болезненным — иногда “по привычке” в объявлении какого-нибудь метода пишу знак равенства, а потом приходится искать ошибки.
Заключение #
На этом список наверняка не исчерпывается, поэтому делитесь в комментариях, как вы шли дорогой приключений, но потом что-то пошло не так…
У языка много положительных черт, о которых вы можете прочитать на официальном сайте, в статьях на хабре и еще много где. Но лично я не согласен с некоторыми архитектурными решениями (классы final by default, java interop) и иногда чувствуется, что языку нехватает единообразия, консистентности. Кроме примера с lateinit
Int приведу еще два. Внутри блоков let
используем it
, внутри with
- this
, а внутри run
, который является комбинацией let
и this
что надо использовать? А у класса String!
можно вызвать методы isBlank()
, isNotBlank()
, isNullOrBlank()
, а “дополняющего” метода вроде isNotNullOrBlank
нет:( После Scala нехватает некоторых вещей - Option
, Either
, matching, каррирования. Но в целом язык оставляет приятное впечатление, надеюсь, что он продолжит достойно развиваться.
P.S. Хабровская подсветка Kotlin хромает, надеюсь, что администрация @habrahabr это когда-нибудь поправит…
UPD: Выстрелы от комментаторов (буду обновлять) #
Неочевидный приоритет оператора elvis. Автор - @senia.
UPD2 #
Обратите еще внимание на статью Kotlin: без Best Practices и жизнь не та.
В комментариях там есть еще один шикарный выстрел от @Artem_zin: возможность переопределить get()
у val
и возвращать значения динамически.
UPD3 #
Еще некоторые новички могут подумать, что операторы and
и or
для булевых переменных - это такой сахар для “нечитаемых” &&
и ||
. Однако это не так: хоть результат вычислений будет тем же, но “старые” операторы вычисляются лениво. Поэтому если вы вдруг напишете так:
if ((i >= 3) and (someArray[i-3] > 0)){
//do something
}
то получите исключение при i<3
.
Скрин опроса: