Совсем недавно вышел релиз Kotlin, а его команда разработчиков предлагала задавать вопросы про язык. Он сейчас на слуху и, возможно, многим хочется его попробовать.
Пару недель назад тимлид сделал для компании презентацию о том, что в Котлине хорошо. Одним из самых интересных вопросов был «А как в Котлине выстрелить себе в ногу?» Так получилось, что ответил на этот вопрос я.
Disclaimer:
Не стоит воспринимать эту статью как «Kotlin — отстой». Хотя я отношусь скорее к категории тех, кому и со Scala хорошо, я считаю, что язык неплохой.
Все пункты спорные, но раз в год и палка стреляет. Когда-то вы себе прострелите заодно и башку, а когда-то у вас получится выстрелить только в полночь полнолуния, если вы предварительно совершите черный ритуал создания плохого кода.
Наша команда недавно закончила большой проект на Scala, сейчас делаем проект помельче на Kotlin, поэтому в спойлерах будет сравнение со Scala. Я буду считать, что Nullable в Kotlin — это эквивалент Option, хотя это совсем не так, но, скорее всего, большинство из тех, кто работал с Option, будут вместо него использовать Nullable.
1. Пост-инкремент и преинкремент как выражения
Цитирую вопрошавшего: «Фу, это ж баян, скучно». Столько копий сломано, миллион вопросов на собеседованиях C++… Если есть привычка, то можно было его оставить инструкцией (statement'ом). Справедливости ради, другие операторы, вроде +=, являются инструкциями.
Цитирую одного из разработчиков, abreslav:
Смотрели на юзкейсы, увидели, что поломается, решили оставить.
Замечу, что у нас тут не С++, и на собеседовании про инкремент спросить особо нечего. Разве что разницу между префиксным и постфиксным.
На нет и суда нет. Разумеется, в здравом уме никто так делать не будет, но случайно — может быть.
var i = 5
i = i++ + i++
println(i)
var a = 5
a = ++a + ++a
println(a)
var b = 5
b = ++b + b++
println(b)
var c = 5
c = c++ + ++c
println(c)
var d = 5
d = d + d++ + ++d + ++d
println(d)
var e = 5
e = ++e + ++e + e++ + e
println(e)
25
28
2. Одобренный способ
val foo: Int? = null
val bar = foo!! + 5
В документации говорится, что так делать стоит только если вы очень хотите получить NullPointerException. Это хороший метод выстрелить себе в ногу: !! режет глаз и при первом взгляде на код все понятно. Разумеется, использование !! предполагается тогда, когда до этого вы проверили значение на null и smart cast по какой-нибудь причине не сработал. Или когда вы почему-то уверены, что там не может быть null.
val foo: Option[Int] = None
val bar = foo.get + 5
3. Переопределение invoke()
Начнем с простого: что делает этот кусок кода и какой тип у a?
class A(){...}
val 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) вы можете накрякать себе беду.
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()
class SlowBro(){
val value: String? = null
fun test(){
if (value == null) {
println("null")
return
}
if (value == "ololo")
println("ololo!")
else
println("alala!")
}
}
SlowBro().test()
Я бы сказал, что это тоже одобренный способ, но при чтении кода это неочевидно, в отличие от !!. В документации немного завуалированно говорится, что, мол, проверять не надо, если что, мы кинем тебе Exception. По идее, этот модификатор используется тогда, когда вы точно уверены, что поле будет инициализированно кем-то другим. То есть никогда. По моему опыту, все поля, которые были lateinit, рано или поздно стали Nullable. Неплохо это поле вписалось в контроллер JavaFX приложения, где Gui грузится из FXML, но даже это «железобетонное» решение было свергнуто после того, как появился альтернативный вариант без пары кнопок. Один раз так получилось, что в SceneBuilder изменил fx:id, а в коде забыл. В первые дни кодинга на Kotlin немного взбесило, что нельзя сделать lateinit Int. Я могу придумать, почему так сделали, но сомневаюсь, что совсем нет способа обойти эти причины (читай: сделать костыль).
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 null
Все просто — к полю идет обращение до того, как оно было инициализировано. Видимо, тут стоит немного доработать компилятор. По идее, если вы пишете код хорошо, такая проблема у вас не должна возникнуть, но всякое бывает, не с потолка же я взял этот пример (коллега себе так выстрелил в ногу, случайно через цепочку методов в редко срабатывающем коде вызвал поле, которое было не инициализировано).
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 null
6. Взаимодействие с Java
Для выстрела тут простор достаточно большой. Очевидное решение — считать все, что пришло из Java, Nullable. Но тут есть долгая и поучительная история. Как я понял, она связана в основном с шаблонами, наследованием, и цепочкой Java-Kotlin-Java. И при таких сценариях приходилось делать много костылей, чтобы заработало. Поэтому решили от идеи «все Nullable» отказаться.
Но вроде как один из основных сценариев — свой код пишем на Kotlin, библиотели берем Java (как видится мне, простому крестьянину-кодеру). И при таком раскладе, лучше безопасность в большей части кода и явные костыли в небольшой части кода, которые видно, чем «красиво и удобно» + внезапные грабли в рантайме (или яма с кольями, как повезет). Но у разработчиков другое мнение:
Одна из основных причин была в том, что писать на таком языке было неудобно, а читать его — неприятно. Повсюду вопросительные и восклицательные знаки, которые не очень-то помогают из-за того, что расставляются в основном, чтобы удовлетворить компилятор, а не чтобы корректно обработать случаи, когда выражение вычисляется в null. Особенно больно в случае дженериков: например, Map<String?, String?>?..
Сделаем небольшой класс на 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)
Тип у 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)
Все логично. Когда мы явно указываем, что нам нужен NotNullable, тогда и ловим исключение. Казалось бы, указывай у всех переменных Nullable, и все будет хорошо. Но если делать так:
printString(j2.get())
то ошибку вы можете обнаружить нескоро.
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 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 body
calling second
calling second body
Всего один перенос строки, который легко при переформатировании удалить/добавить переключает поведение. Основано на реальных событиях: была цепочка методов «сделай в background» и «потом сделай в ui треде». И был метод «сделай в ui» с таким же именем.
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 входным аргументом. Думаю, что с развитием языка и компилятор поумнеет, и этот пункт — временный.
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")
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")
baz is not in cache!
IamNull is not in cache!
Зачем хранить null? Например, чтобы показать, что результат не вычислим. Конечно, тут было бы правильнее использовать Option или Either, но, к сожалению, ни того, ни другого в стандартной библиотеке нет (но есть, например, в funKTionale). Более того, как раз при реализации Either, я наступил на грабли этого пункта и предыдущего. Решить эту проблему с «двойным Nullable» можно, например, возвратом Pair или специального data class.
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")
}
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 это когда-нибудь поправит…
Автор: ov7a