Привет, Хабр! Сегодня я расскажу о фиче, которую обнаружил в стандартной библиотеке котлина и реализация которой мне показалась неочевидной, а найти достаточного полного материала в интернете не удалось.

Пришлось копнуть, а что бы не было скучно, сделаю это вместе с вами.

Итак, далее в статье:

  • Какого черта у null объектов можно вызывать методы?

  • Как договориться с компилятором?

  • Чем функции-расширения отличаются от родных методов класса?

Начнем. Сразу оговорюсь, я не мега-продвинутый котлин девелопер, еще буквально вчера писал восхищенную статью про свои открытия от языка, а сегодня уже лезу под капот.

Когда я начал писать на котлине (после java, конечно же), одной из вещей, которая привлекла мое внимание, стала функция isNullOrEmpty() которую можно очень ловко вызывать на различных объектах стандартной библиотеки котлина. Вот черт, подумал я, действительно, как же я раньше не додумался! Кому надо obj == null , когда есть obj.isNull().

Сел и написал следующее :

public class MyClass{
  public boolean isNull(){
    return this == null;
  }
}

Я понимаю, что листинг сумасшедший, тип возвращаемого аргумента мало того, что указан явно, так этот шельмец еще и стоит перед(!) именем функции, поэтому котлиноводы, держите:

 class MyClass(){
   fun isNull() = this == null 
 }

Ну, думаю, все, улучшил читаемость. Запускаю:

fun main() {
  val myClass:MyClass? = null
  println(myClass!!.isNull())
}

И получаю:

Exception in thread "main" java.lang.NullPointerException

бедняга вызвал метод на null-объекте и получил NPE
бедняга вызвал метод на null-объекте и получил NPE

На самом деле, сомнения закрались еще при вызове метода isNull(): компилятор потребовал поставить !! перед вызовом метода:

Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type MyClass?

Решение оказалось просто и элегантно, и, быть может, уже пришло вам на ум, но оно ждет вас в конце статьи вместе с бонусным фокусом. А пока, лезем в библиотечный класс Strings.kt:

Находим там такое чудо:

@kotlin.internal.InlineOnly
public inline fun CharSequence?.isNullOrEmpty(): Boolean {
  contract {
    returns(false) implies (this@isNullOrEmpty != null)
  }
  return this == null || this.length == 0
}

 

Тут много всякого интересного, так что давайте по порядку:

  1. Аннотация @kotlin.internal.InlineOnly в сочетании с ключевым словом inline в сигнатуре, делает следующее:

    Код, вызывающий нашу функцию и код самой этой inline функции объединяются и подставляются непосредственно в место вызова. Статейка на этот счет.

    Более того, java-метод, соответствующий этой inline функции, становится приватным, соответственно не останется иного способа ее использования, кроме прямого встраивания в местах вызова. Узнал отсюда.

    Внимание вопрос: а зачем это тут?

    Inline методы вообще-то лямбды должны принимать. Без них, это ключевое слово просто не имеет смысла.

    Или все-таки имеет? Я нашел такой вопрос на stackOverflow, и умные люди объяснили, что это сделано для того, чтобы:

    1.1) Уменьшить количество методов в артефактах (это важно для android) – еще раз акцентирую, это уменьшение произойдет не за счет inline, а за счет комбинации inline+ @InlineOnly

    1.2) Поддерживать reified type parameters. Советую перейти по ссылке, и прочитать, но если коротко, ключевое слово reified доступно только с inline методами и позволяет работать с дженериками как с обычными классами (они не стираются во время компиляции)

    В нашем случае просто примем это как должное. Ну захотели разработчики уменьшить количество методов после компиляции, их право.

  2. Далее в сигнатуре видим CharSequence?.isNullOrEmpty() – агааа, это функция-расширение! Попалась.

    Итак, чем же замечательна функция-расширение? А тем, что, по сути своей, это обычный декоратор. Вызовы этой функции не идут в декорируемый класс, именно поэтому мы можем провернуть трюк с  this == null. По сути, мы не проверяем, является ли настоящий this нуллом. Мы проверяем, является ли декорируемый объект нуллом.

  3. И затем контракт:

    contract {
      returns(false) implies (this@isNullOrEmpty != null)
    }

    Вот сейчас будет интересно.
    Давайте, сначала, я попробую просто перевести эту строчку на русский:

    Итак, компилятор! если функция вернет false, то имей ввиду что этот объект не null и не пуст.

    Теперь справка:

    Контракты с компилятором находятся в экспериментальном API.

    Любые места использования контрактов должны быть помечены аннотацией @kotlin.contracts.ExperimentalContracts либо @OptIn(kotlin.contracts.ExperimentalContracts::class)

    Любое использование частей программы, аннотированных @ExperimentalContracts, должно быть согласовано либо путем аннотирования этого использования аннотацией OptIn, например @OptIn(ExperimentalContracts::class), либо с помощью аргумента компилятора -opt-in=kotlin.contracts.ExperimentalContracts

    Хм, какая-то экспериментальная фича. Давайте разберемся, что она делает на небольшом примере (примеры сделаны по аналогу отсюда, чуть ли не единственный нормальный материал по контрактам в котлине):

    Итак, сделаем класс питомца:

    class Pet(val name: String = "default pet name")

    Теперь сделаем отдельную функцию, которая будет принимать ЧИСЛО. А потом печатать в консоль ИМЯ ПИТОМЦА из этого аргумента. Как вам, нехило?

    fun printPetName(arg:Int?){
      println(arg.name)
    }

    Компилятор такой прикол не оценит и скажет:

    Unresolved reference: name (и правильно сделает)

    А я хочу! Хочу обмануть компилятор. Душа просит из числа сделать себе домашнего питомца.

    Котлин нехотя мне это разрешает, стоит лишь написать:

    private fun runContract(arg: Any?){
      contract {
        returns() implies (arg is Pet)
      }
    }

    Поясняю: в этот метод я вынес объявление контракта, который говорит, что после того, как эта функция завершится ( return() – вернет ничего), компилятор начнет свято верить, что переданный в этот метод аргумент, это объект класса Pet.

    Добавляем метод с контрактом в метод печати имени питомца:

    fun printPetName(arg:Int?){
      runContract(arg)
      println(arg.name)
    }

    Как и говорилось ранее, буквально все, что можно, нужно обвесить кричащими аннотациями, мол использую экспериментальное API котлина!!

    Поэтому итоговый код выглядит вот так:

    import kotlin.contracts.ExperimentalContracts
    import kotlin.contracts.contract
    
    class Pet(val name: String = "default pet name")
    
    @OptIn(ExperimentalContracts::class)
    fun main() {
      printPetName(10)
    }
    
    @ExperimentalContracts 
    fun printPetName(arg:Int?){
      runContract(arg)
      println(arg.name)
    }  
    
    @ExperimentalContracts 
    fun runContract(arg: Any?){
      contract {
        returns() implies (arg is Pet)
      }
    }
    )
    )

    И, теперь уже, с чистой совестью, мы можем упасть с рантаймом:

    Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class Pet

    Безумие какое то, но весело.

Но что-то нас занесло. Давайте возвращаться к оригинальному вопросу: как же нам организовать метод isNull() для самописного класса?

Мы выяснили, что оригинальные методы класса не могут вызываться из null-объекта (да и никогда не могли). Но мы можем задекорировать наш класс функцией-расширением. Давайте сделаем это:

class MyClass(){}
fun MyClass?.isNull() = this == null

Все оказалось очень просто, как видите.

А теперь обещанный бонусный фокус.

Я буквально недавно его опробовал: на полном серьезе рассказывал коллеге-котлинисту о новой фиче котлина в области обработки NPE, которая выглядит следующим образом:

больше никаких NPE!
больше никаких NPE!

Для того, чтобы провернуть такой трюк, мы, конечно же, создаем функцию-расширение в отдельном файле и вызываем ее:

fun MyClass?.printAuthоrUsername(){
  if(this!=null) println("youngmyn")
  else println("Method calls are not allowed on a nullable receiver of type MyClass?")
}

А что бы из оригинального кода вызывалась именно функция-расширение, а не оригинальная (в котлине, кстати, это жестко зафиксировано  – "если класс имеет собственную функцию и определена функция расширения, которая применяется к тому же классу, имеет то же имя и применима к заданным аргументам, то собственная всегда выигрывает") – я заменил латинскую букву ‘o’ на ее брата из кириллицы и оставил в IDE только подсветку синтаксиса.

fun main() {
  val myClass : MyClass? = null
  myClass.printAuthоrUsername()
}

class MyClass(){
  fun printAuthorUsername() = println("youngmyn")
}

Трюк детский, но мне практически удалось убедить в его правдивости взрослого, бородатого гика-котлиниста.

На этом откланяюсь. Всем, кто честно дочитал до этого момента – респект. Надеюсь, получилось читабельно.

Источники:

Inline-functions docs

Inline под капотом [хабр]

Дискуссия про @InlineOnly

Зачем использовать inline без лямбд?

Reified type parameters docs

Кратко про функции-расширения в формате собеса

Исходники ContractBuilder

Принцип работы и ограничения контрактов в котлин (с примерами)

extensions docs

Комментарии (3)


  1. panzerfaust
    11.09.2024 04:44

    Итак, чем же замечательна функция-расширение? А тем, что, по сути своей, это обычный декоратор.

    Этот инструмент является прямой реализацией переопределения методов паттерна проектирования Декоратор

    Что характерно, в официальной доке прямо написано:

    Kotlin provides the ability to extend a class or an interface with new functionality without having to inherit from the class or use design patterns such as Decorator

    То есть это прям вообще не декоратор. Это просто трюк компилятора. Результат похож, но мало ли у чего результат похож, если под капотом разные механизмы.

    Сначала на собесах повторяли мантру, что корутины это легковесные потоки. Теперь кто-то будет говорит, что расширение это декоратор.


    1. youngmyn Автор
      11.09.2024 04:44

      Результат не просто похож, в доках прям написано - теперь можно обойтись без декоратора.

      То есть расширения включают весь функционал декоратора и даже больше. Поэтому , в рамках определенной терминологии, в конкретном контексте мы можем относиться к нему, как к декоратору, с точки зрения основных функций декоратора.


  1. gBear
    11.09.2024 04:44

    Имхо, если говорить о Kotlin Contracts, то сильно не хватает ссылки на соответствующий KEEP