Меня снова спросили за Optional
Несмотря на то, что использование Optional самая настоящая рутина для любого iOS-разработчика, в тонкости реализации этого механизма мы погружаемся только при первом знакомстве с языком. Предлагаю чуть углубиться, чтобы уверенно говорить на эту тему с коллегой или интервьюером.
Так как мы знаем (верю в вас), что Optional представляет собой перечисление с двумя кейсами, в одном из которых лежит ассоциированное значение, сразу напишем простую реализацию.
enum MyOptional {
case none
case some(T)
}
Т
— это дженерик, то есть мы не привязываемся к типу, а создаём универсальный «контейнер», который даст нам возможность положить в него любой тип.
2. Инициализатор
Можем ли мы, имея такую структуру, сразу присвоить этому свойству с нашим типом значение или nil без использования кейсов? Давайте разбираться.
struct Person {
let name: String
let age: Int
}
class TestClass {
func todo() {
let optionalNil: MyOptional = nil
// Ошибка: 'nil' cannot initialize specified type 'MyOptional'
let optionalInt: MyOptional = 5
// Ошибка: Cannot convert value of type 'Int' to specified type 'MyOptional'
let optional2: MyOptional = Person(name: "Lola", age: 24)
// Ошибка: Cannot convert value of type 'Person' to specified type 'MyOptional'
}
}
Как видим, нет, появляются ошибки. Попробуем их разрешить по одной.
extension MyOptional: ExpressibleByNilLiteral {
init(nilLiteral: ()) {
self = .none
}
}
Для решения проблемы с присваиванием nil нужно подписать наш кастомный опционал под протокол ExpressibleByNilLiteral. Теперь, когда система увидит после знака равно nil, то сразу вызовет описанный инициализатор и присвоит свойству кейс .none.
extension MyOptional: ExpressibleByIntegerLiteral where T == Int {
init(integerLiteral value: Int) {
self = .some(value)
}
}
Похожий подход используется для реализации присваивания литералов (Int, Double, String, Nil, Collections). Подписываемся под соответствующий протокол, в нашем случае ExpressibleByIntegerLiteral, не забываем указать, что дженерик должен быть соответствующего типа. Затем кладем в кейс .some ассоциированное значение.
extension MyOptional {
init(_ value: T) {
self = .some(value)
}
}
// Теперь можем инициализировать вот так:
let optional2: MyOptional = .init(Person(name: "Lola", age: 24))
// Проще ли это, чем просто передать кейс, вопрос открытый.
А вот для остальных случаев инициализация через знак равно недоступна, поэтому реализуем обычный init с дженериком. Скорее всего эта реализация вам и пришла в голову поначалу.
2. Распаковка через nil-coalescing (оператор ??)
Самый популярный способ распаковки, когда нужно на месте подставить дефолтное значение, если опционал пришел пустой.
Ниже две реализации, рассмотрим обе.
extension MyOptional {
static func ??(optional: MyOptional, defaultValue: T) -> T {
switch optional {
case .none:
return defaultValue
case let .some(unwrappedValue):
return unwrappedValue
}
}
}
Простой вариант, который сработает, но ему немного не хватает до корректного вида.
extension MyOptional {
static func ?? (optional: MyOptional, defaultValue: @autoclosure () -> T) -> T {
switch optional {
case .none:
return defaultValue()
case let .some(unwrappedValue):
return unwrappedValue
}
}
}
Вот теперь правильно.
В чём же разница?
Во-первых, мы передаём дефолтное значение как замыкание. Это важно: во второй версии код справа от ??
выполнится только при отсутствии значения в опционале. Такое поведение называется «ленивое вычисление». В первой версии такой оптимизации нет — выражение будет вычислено в любом случае.
Во-вторых, использование перед замыканием модификатора @autoclosure
позволяет писать код для вызова без обязательного помещения их в фигурные скобки.
3. Force unwrap
Сразу скажу, что ниже представлен слегка "душный" вариант, скорее всего достаточно будет обычного имени функции, но я решил чуть копнуть.
extension MyOptional {
static prefix func ! (optional: MyOptional) -> T {
switch optional {
case let .some(value):
return value
case .none:
fatalError("Ты сказал, что ты шаришь в этой теме.")
}
}
}
Обратим внимание на ключевое слово prefix
. Поскольку мы ставим !
сразу после имени свойства без пробела, мы обязаны указать, что наш оператор префиксный.
При наличии значения достаем его аналогично коду выше, а вот в случае .none — вызываем fatalError
(крашим приложение). Так делает и стандартный Swift.
4. Операторы равенства и сравнения
На собеседовании часто просят реализовать механизм сравнения опционалов, и вы можете поспешить реализовывать протокол Comparable. Опомнитесь! Для реализации протокола Comparable сначала нужно реализовать протокол Equatable. По этой причине предлагаю начать с него.
extension MyOptional: Equatable where T: Equatable {
static func == (lhs: Self, rhs: Self) -> Bool {
switch (lhs, rhs) {
case let (.some(leftValue), .some(rightValue)):
return leftValue == rightValue
case (.none, .none):
return true
default:
return false
}
}
}
Важно понимать, что помимо самого типа MyOptional, наш дженерик тоже должен быть подписан под протокол Equatable. То же самое касается реализации протокола Comparable.
Используя switch, мы сравниваем значения:
— если оба опционала .none → возвращаем true
— если оба .some и содержат одинаковые значения → true
— во всех остальных случаях → false
extension MyOptional: Comparable where T: Comparable {
static func < (lhs: MyOptional, rhs: MyOptional) -> Bool {
switch (lhs, rhs) {
case let (.some(leftValue), .some(rightValue)):
return leftValue < rightValue
default:
return false
}
}
}
Тем же образом реализуем оператор <
.
Как видите, все довольно просто.
Бонусные вопросы с собесов
Почему мы реализуем только оператор <
? Как под капотом работает оператор >
?
Ответ: Оператор >(больше) фактически вызывает оператор <, но с поменянными аргументами.
Нужно ли реализовывать операторы <=
>=
?
Ответ: Операторы <= и >=автоматически выводятся Swift через < и == благодаря реализации Comparable и Equatable. Дописывать их вручную не нужно.
Заключение
Надеюсь, данная статья добавит ясности в понимании работы опционалов, если ее не хватало.
Буду рад получить обратную связь в любом удобном для вас виде.