Этот пост является вольным переводом статьи Swift vs. Kotlin?—?the differences that matter by Krzysztof Turek


Вы наверняка видели это сравнение Swift и Kotlin: http://nilhcem.com/swift-is-like-kotlin/. Довольно интересное, правда? Я согласен, что в этих языках много схожего, но в этой статье я обращу внимание на некоторые аспекты, которые их все-таки разнят.


Я занимаюсь Android-разработкой с 2013 и большую часть времени разрабатывал приложения на Java. Недавно же у меня появилась возможность попробовать iOS и Swift. Я был впечатлен тем, что на Swift получается писать очень клевый код. Если вы приложите усилия — ваш код будет похож на поэму.


Через семь месяцев я вернулся к Android. Но вместо Java начал кодить на Kotlin. Google объявил на Google IO 2017, что Kotlin теперь официальный язык для Android. И я решил учить его. Мне не понадобилось много времени, чтобы заметить сходство между Kotlin и Swift. Но я бы не сказал, что они очень похожи. Ниже я покажу отличия между ними. Я не буду описывать все, а только те, которые мне интересны. Рассмотрим примеры.


Структуры vs. Data-классы. Значения и ссылки


Структуры и Data-классы — упрощенные версии классов. Они похожи в использовании, выглядит это так


Kotlin:


data class Foo(var data: Int)

Swift:


struct Foo {
    var data: Int
}

Но класс по прежнему остается классом. Этот тип передается по ссылке. А вот структура — по значению. "И что?" спросите вы. Я объясню на примере.


Давайте создадим наш data-класс в Kotlin и структуру в Swift, а затем сравним результаты.


Kotlin:


var foo1 = Foo(2)
var foo2 = foo1
foo1.data = 4

Swift:


var foo1 = Foo(data: 2)
var foo2 = foo1
foo1.data = 4

Чему равно data для foo2 в обоих случаях? Ответ 4 для data-класса Kotlin и 2 для структуры на Swift.



Результаты отличаются, потому что var foo2 = foo1 в Swift создает копию экземпляра структуры (детальнее тут), а в Kotlin — еще одну ссылку на тот же объект (детальнее тут)


Если вы работаете с Java, вы вероятно знакомы с паттерном Defensive Copy. Если нет — наверстаем упущенное. Здесь вы найдете больше информации по теме.


В общем: существует возможность изменения состояния объекта изнутри или извне. Первый вариант — предпочтительнее и более распространен, а вот второй — нет. Особенно когда вы работаете со ссылочным типом и не ожидаете изменений его состояния. Это может осложнить поиск багов. Для предотвращения этой проблемы, вам следует создавать защищенную копию мутабельного объекта перед тем как передавать его куда-либо. Kotlin гораздо полезнее в таких ситуациях, чем Java, но по неосторожности все еще могут возникать проблемы. Рассмотрим простой пример:


data class Page(val title: String)
class Book {
    val pages: MutableList<Page> = mutableListOf(Page(“Chapter 1”), Page(“Chapter 2”))
}

Я объявил pages как MutableList, потому что хочу их менять внутри этого объекта (добавлять, удалять и т.п.). Pages не private, потому что мне нужен доступ к их состоянию извне. Пока все идет нормально.


val book = Book()
print(“$book”) // Book(pages=[Page(title=Chapter 1), Page(title=Chapter 2)])

Теперь у меня есть доступ к текущему состоянию книги:


val bookPages = book.pages

Я добавляю новую страницу в bookPages:


bookPages.add(Page(“Chapter 3”))

К сожалению, я также изменил состояние исходной книги. А это совсем не то, чего я хотел.


print(“$book”) // Book(pages=[Page(title=Chapter 1), Page(title=Chapter 2), Page(title=Chapter 3)])

Мы можем воспользоваться защищенной копией, чтобы избежать этого. Это очень легко в Kotlin.


book.pages.toMutableList()

Теперь у нас все хорошо. :)


А что же Swift? Тут все работает из коробки. Да, массивы — это структуры. Структуры передаются по значению, как мы уже упоминали выше, поэтому когда вы пишете:


var bookPages = book.pages

вы работаете с копией списка страниц.


Таким образом мы имеем дело с передачей данных по значению. Это очень важно для понимания отличий, если вы не хотите испытывать головную боль во время отладки. :) Многие "объекты" являются структурами в Swift, например Int, CGPoint, Array и т.п.


Интерфейсы и Протоколы и Расширения


Это моя любимая тема. :D


Начнем со сравнения интерфейса и протокола. В принципе, они идентичны.


  • Оба могут требовать реализации определенных методов в классе/структуре;
  • Оба могут требовать объявления определенного свойства. Свойство может быть доступным на чтение/запись или только на чтение.
  • Оба* позволяют добавить реализацию метода по-умолчанию.

Кроме того, для протоколов может потребоваться определенный инициализатор (конструктор в Kotlin).


Kotlin:


interface MyInterface {
    var myVariable: Int
    val myReadOnlyProperty: Int

    fun myMethod()
    fun myMethodWithBody() {
        // implementation goes here
    }
}

Swift:


protocol MyProtocol {
    init(parameter: Int)

    var myVariable: Int { get set }
    var myReadOnlyProperty: Int { get }

    func myMethod()
    func myMethodWithBody()
}

extension MyProtocol {
    func myMethodWithBody() {
        // implementation goes here
    }
}

*Обратите внимание, что вы не можете добавить реализацию метода по-умолчанию прямо внутри протокола. Вот почему я добавил звездочку к последнему пункту списка. Вам нужно добавить расширение для этого. И это хороший способ перейти к более интересной части — расширениям!


Расширения позволяют добавлять функционал к существующим классам (или структурам ;)) не наследуя их. Это так просто. Согласитесь, это крутая возможность.


Это что-то новое для Android-разработчиков, поэтому нам нравится пользоваться этим постоянно! Создавать расширения в Kotlin — не запускать ракеты в космос.


Вы можете создавать расширения для свойств:


val Calendar.yearAhead: Calendar
get() {
    this.add(Calendar.YEAR, 1)
    return this
}

или для функций:


fun Context.getDrawableCompat(@DrawableRes drawableRes: Int): Drawable {
    return ContextCompat.getDrawable(this, drawableRes) ?: throw NullPointerException("Can not find drawable with id = $drawableRes")
}

Как видите, мы не использовали здесь никаких ключевых слов.


В Kotlin есть некоторые предопределенные расширения, которые довольно круты, например "orEmpty()" для опциональных строк:


var maybeNullString: String = null
titleView.setText(maybeNullString.orEmpty())

Это полезное расширение выглядит так:


public inline fun String?.orEmpty(): String = this ?: ""

'?:' пытается получить значение из 'this' (что является текущим значением нашей строки). Если же там будет null, взамен будет возвращена пустая строка.


Так-с, теперь посмотрим на расширения в Swift.


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


Если вы будете искать расширение подобное "orEmpty()" — у меня для вас плохие новости. Но можно его добавить, не так ли? Давайте попробуем!


extension String? {
    func orEmpty() -> String {
        return self ?? ""
    }
}

но вот что вы увидите:




Опционал в Swift — это generic-перечисление, с заданным типом Wrapped. В нашем случае Wrapped — это строка, поэтому расширение будет выглядеть так:


extension Optional where Wrapped == String {
    func orEmpty() -> String {
        switch self {
        case .some(let value):
            return value
        default:
            return ""
        }
    }
}

и в деле:


let page = Page(text: maybeNilString.orEmpty())

Выглядит сложнее, чем Kotlin-аналог, не так ли? И, к сожалению, есть еще и недостаток. Как вы знаете опционал в Swift — generic-перечисление, поэтому ваше расширение будет доступно для всех опциональных типов. Выглядит не очень хорошо:



Однако компилятор защитит вас и не скомпилирует этот код. Но если вы добавите больше таких расширений — ваша автоподсказка будет забита мусором.


Значит Kotlin-расширения удобнее чем в Swift? Я бы сказал, что расширения в Swift предназначены для других целей ;). Android-разработчики, держитесь!


Протоколы и расширения созданы, чтоб работать вместе. Вы можете создать свой протокол и расширение для класса, чтоб соответствовать этому протоколу. Это звучит безумно, но это еще не все! Есть такая вещь, как условное соответствие протоколу. Это означает, что класс/структура может соответствовать протоколу при выполнении определенных условий.


Допустим у нас есть много мест, где необходимо показать всплывающий alert. Нам нравится принцип DRY и мы не хотим копипастить код. Мы можем решить эту проблему используя протокол и расширение.


Сначала создадим протокол:


protocol AlertPresentable {
    func presentAlert(message: String)
}

Затем, расширение с реализацией по-умолчанию:


extension AlertPresentable {
    func presentAlert(message: String) {
        let alert = UIAlertController(title: “Alert”, message: message, preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: “OK”, style: .default, handler: nil))
    }
}

Так-с, метод presentAlert только создает alert, но ничего не показывает. Нам нужна ссылка на вью-контроллер для этого. Можем ли мы передать его как параметр в этот метод? Не очень хорошая идея. Давайте воспользуемся условием Where!


extension AlertPresentable where Self: UIViewController {
    func presentAlert(message: String) {
        let alert = UIAlertController(title: “Alert”, message: message, preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: “OK”, style: .default, handler: nil))
        self.present(alert, animated: true, completion: nil)
    }
}

Что у нас тут? Мы добавили специфическое требование к расширению нашего протокола. Оно предназначено только для UIViewController. Благодаря этому мы можем пользоваться методами UIViewController в методе presentAlert. Это позволяет нам вывести alert на экран.


Идем дальше:


extension UIViewController: AlertPresentable {}

Теперь у всех UIViewController появилась новая возможность:



Также, комбинация протоколов и расширений очень полезна для тестирования. Ребята, сколько раз вы пытались тестировать Android final-класс в своем приложении? Это не проблема для Swift.


Приглядимся к этой ситуации и предположим, что у нас есть final-класс в Swift. Если мы знаем сигнатуру метода, то можем создать протокол с таким же методом, а затем добавить расширение, реализующее этот протокол, к нашему final-классу, и вуаля! Вместо непосредственного использования этого класса — мы можем использовать протокол и легко тестировать. Пример кода вместо тысячи слов.


final class FrameworkMap {
    private init() { … }
    func drawSomething() { … }
}

class MyClass {
    …
    func drawSomethingOnMap(map: FrameworkMap) {
        map.drawSomething()
    }
}

В тесте нам нужно проверить вызывается ли метод drawSomething у объекта map при отработке метода drawSomethingOnMap. Это может быть сложно даже с Mockito (хорошо известной тест-библиотекой для Android). Но с протоколом и расширением — это будет выглядеть так:


protocol Map {
    func drawSomething()
}

extension FrameworkMap: Map {}

И теперь ваш метод drawSomethingOnMap использует протокол вместо класса.


class MyClass {
    …
    func drawSomethingOnMap(map: Map) {
        map.drawSomething()
    }
}

Sealed-классы — перечисления на стероидах


Наконец, я хотел бы упомянуть перечисления.


Нет отличий между Java-перечислениями и Kotlin-перечислениями, поэтому тут мне добавить нечего. Но у нас есть кое-что новое взамен, и это "супер-перечисления" — sealed-классы. Откуда взялось понятие "супер-перечисление"? Обратимся к документации Kotlin:


"… Они, в некотором смысле — расширения для enum-классов: набор возможных значений для перечислений также ограничен, но каждая enum-константа существует только в единственном экземпляре, в то время как наследник sealed-класса может иметь множество экземпляров, которые могут хранить состояние."

Окей, круто, они могут хранить состояние, но как мы можем этим пользоваться?


sealed class OrderStatus {
    object AwaitPayment : OrderStatus()
    object InProgress : OrderStatus()
    object Completed : OrderStatus()
    data class Canceled(val reason: String) : OrderStatus()
}

Это sealed-класс, который является моделью статуса заказа. Очень похоже на то, как мы работаем с перечислениями, но с одной оговоркой. Значение Canceled содержит причину отмены. Причины отмены могут быть разными.


val orderStatus = OrderStatus.Canceled(reason = "No longer in stock")
…
val orderStatus = OrderStatus.Canceled(reason = "Not paid")

Мы не можем делать так с обычными перечислениями. Если значение перечисления создано — его уже не изменить.


Вы обратили внимание, на другие отличия? Я воспользовался еще одной фишкой sealed-класса. Это — связанные данные разных типов. Классическое перечисления предполагает передачу связанных данных для всех вариантов значений перечисления, и все значения должны быть одного и того же типа.


В Swift есть эквивалент sealed-класса и он называется… перечисление. Перечисление в Kotlin — это просто пережиток Java, и 90% времени вы будете пользоваться sealed-классами. Трудно отличить sealed-класс от перечисления Swift. Они отличаются только названием и, конечно же, sealed-класс передается по ссылке, а перечисление в Swift — по значению. Пожалуйста, поправьте меня, если я не прав.


Мы не прощаемся


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

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


  1. haoNoQ
    09.03.2018 19:14
    -1

    ваше расширение будет доступно для всех опциональных типов. Выглядит не очень хорошо: (...) Однако компилятор защитит вас и не скомпилирует этот код.

    То есть это не особенность языка, и ничего там не будет доступно, а просто просчёт в системе автодополнения, которая не была готова к таким фокусам(?)


    1. lgorSL
      09.03.2018 23:25

      Мне тоже не понятен этот момент. Там же чётко написано ограничение


      extension Optional where Wrapped == String { ...

      Я так же могу в котлин написать:


      class Wrapper<T>(val t: T)
      
      fun Wrapper<String>.printMe() {
          println(t)
      }

      Однако компилятор защитит вас и не скомпилирует этот код. Но если вы добавите больше таких расширений — ваша автоподсказка будет забита мусором.

      В котлине автоподсказка мусором не забивается. Может, виновата ide, а не язык?


  1. pingwinator
    09.03.2018 19:52
    +1

    кстати, для

    extension Optional where Wrapped == String {
        func orEmpty() -> String {
            switch self {
            case .some(let value):
                return value
            default:
                return ""
            }
        }
    }

    можно было оставить и изначальную реализацию orEmpty()
    extension Optional where Wrapped == String {
        func orEmpty() -> String {
            return self ?? ""
        }
    }


    1. s_suhanov Автор
      10.03.2018 11:19

      Точно. Наверное автор оригинального поста так написал, чтоб было более понятно что там в опционале "под капотом" происходит. :)


  1. mahin-tim
    09.03.2018 20:25

    Это мое больное воображение, или значки в начале правда увеличиваются?


    1. s_suhanov Автор
      10.03.2018 11:20

      Не обратил внимания, но да. Таки оптическая иллюзия имеет место быть. :)


  1. Skycaptain
    10.03.2018 07:17
    -1

    Бла-бла-бла, сейчас популярен javascript, давайте деградируем до его уровня...


  1. fogone
    10.03.2018 08:07

    А это совсем не то, чего я хотел.

    Именно — передача по ссылке/значению очень субъективно. Лично на мой взгляд, передача по ссылке по-умолчанию на порядок удобнее и логичнее. Особенно если мои дата-классы иммутабельные. А если надо скопировать, то есть метод `copy` из коробки. Конечно бывают кейзы, когда хотелось бы иметь value-типы и в жвм возможно их скоро завезут, но таких случаев значительно меньше, чем те, что покрываются обычными дата-классами.

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

    Да это приятная фича языка поддерживаемая платформой (в отличии от жвм), только примеры про неё ничего не говорят.
    Это может быть сложно даже с Mockito

    Серьезно? Если у вас есть интерфейс, нет никакой проблемы замокировать его. С файналами не так всё просто конечно, но пример совершенно это не показывает.
    Они отличаются только названием и, конечно же, sealed-класс передается по ссылке, а перечисление в Swift — по значению.

    Сначала опять про ссылки и значения. Для перечислений нет никакой разницы, передавать адрес константы или её номер. А для sealed-классов, та же дилемма, что и в первом пункте. И да, различий нет уж и много, но они есть.


    1. s_suhanov Автор
      10.03.2018 09:29

      Да, про ссылки/значения — холивар может быть вечным. Вот, например, есть у вас объекты типа «монитор», и у них есть свойство «разрешение». Это свойство состоит из двух полей: ширина, высота. Вопрос: какого типа должно быть свойство «разрешение»? Класс или структура? Должно ли оно передаваться по ссылке или по значению. И если у меня есть два монитора с одинаковым разрешением, то меняя разрешение на одном из них ожидаю ли я, что оно изменится и на втором? :)


      Про Мокито вы невнимательно прочитали. Речь о невозможности замокать именно final-класс, а не интерфейс. И в этом и преимущество Свифта: можно даже final-класс подписать на свой протокол (интерфейс) и мокать уже протокол (интерфейс). Главное, чтоб в протоколе (интерфейсе) сигнатуры методов совпадали с тем, что объявлено в final-классе.


      1. fogone
        10.03.2018 09:50
        +1

        да, прошу прощения, неправильно понял, всё верно про возможность мокировать по тайпклассу. Очень это приятная фича, которой нет в жвм платформе. Есть реализация в форке котлина, но у этого всего плохая совместимость с джавой и сложность в корректной поддержке во всех аспектах, так что не факт, что тайпклассы появятся даже как экспериментальная фича в котлине


      1. Dimezis
        10.03.2018 12:58

        Ну всё-таки в Mockito 2 final классы мокаются без проблем.


      1. Throwable
        10.03.2018 16:11

        Передача по ссылке или значению — это архаизмы реализаций компиляторов и языков прошлых лет, когда это имело смысл. На сегодняшний день важно лишь, что описываемая сущность является мутабельной или иммутабельной. В случае иммутабельной сущности нам не важно передается ли она по значению или по ссылке и где физически она находится в памяти — это уже внутренние проблемы компилятора и рантайма. При операциях с иммутабельными сущностями каждый раз создается новый экземпляр. Таким образом иммутабельные классы полностью идентичны value type (подразумевается наличие equals()/hashCode()).


        В случае же мутабельной сущности, мы имеем каждый раз дело с экземпляром, занимающим определенное место в памяти, и поэтому осознанно работаем с ним по ссылке (некоему хэндлу, если угодно). Для передачи информации о внутреннем состоянии мутабельного объекта вовне есть два способа: хороший — обернуть его иммутабельным врапером, либо плохой: просто сделать defensive copy и отдать на растерзание. Структуры в Свифте — это просто реализация defencive copying по умолчанию, тогда как в Котлине это нужно делать въявную. Мне кажется не стоило делать отдельный класс объектов языка только лишь для реализации одного паттерна. Тем более, что изначально структуры (записи) в языках программирования означали немножко другое и появились задолго до классов.


  1. novikovag
    10.03.2018 13:26
    -2

    Пойду замокаю себе еще немножечко смузи.


  1. moonster
    10.03.2018 16:05
    -1

    Необходимость мокать final классы, говорит о просветах в дизайне.


    1. moonster
      12.03.2018 12:07

      Не очень понятно, за что минусы. За стиль? Или кто-то полагает, что моки из final классов — это хорошо?