Этот пост является вольным переводом статьи 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 — по значению. Пожалуйста, поправьте меня, если я не прав.
Мы не прощаемся
Существует еще нюансы влияния управления памятью на способ написания кода. Я знаю, что не охватил все аспекты, и это потому, что я еще учусь. Если вы, ребята, заметили какие-либо другие отличия между этими двумя языками — дайте мне знать. Я всегда открыт к новому!