1. Чому «порожньо» — це частина контракту
Коли ви вперше пишете pop(), дуже хочеться зробити його «зручним»: нехай просто повертає T. А якщо стека немає — ну… «якось потім». Саме в цей момент код починає жити власним життям і одного дня тихо перетворюється на мем: “It works on my machine”. Порожній стек — звична реальність, і хороший API має чесно це відображати.
Важливо: контракт методу має чесно описувати світ. Якщо метод може не повернути значення, це має бути видно в сигнатурі. Інакше ви перекладаєте відповідальність на користувача вашого типу — тобто на майбутнього себе, який за тиждень уже нічого не пам’ятає й дуже впевнений, що стек «точно не порожній».
Класичний приклад зі стандартної бібліотеки Swift добре показує цей принцип: є операції «remove» і «pop», які відрізняються саме ставленням до порожнечі. Методи на кшталт removeLast() вимагають непорожньої колекції й повертають не Optional, а методи на кшталт popLast() повертають Optional і віддають nil, якщо елементів немає. Це пряме визнання того, що «порожньо» може бути нормальним сценарієм.
Для подальшої розмови закріпімо базовий стек, від якого будемо відштовхуватися:
struct Stack<T> {
private var items: [T] = []
mutating func push(_ value: T) {
items.append(value)
}
mutating func pop() -> T? {
items.popLast()
}
var peek: T? {
items.last
}
}
Тут ми вже обрали Optional-контракт: pop() і peek повертають T?. Але наше завдання — зрозуміти, коли це доречно, а коли краще throws або Result.
2. Optional: «порожньо» як нормальний результат
Коли ви використовуєте Optional, ви ніби кажете користувачу API: «Так, елемента може не бути, і це нормально. Просто перевірте». Це схоже на ситуацію в кафе, де вам чесно кажуть: «Сьогодні чизкейк закінчився». Це не катастрофа і не привід викликати охорону — просто оберіть щось інше.
Optional-контракт зазвичай найкраще працює тоді, коли порожнеча — очікуваний сценарій. Наприклад, стек може бути тимчасовим сховищем, історією дій або допоміжною структурою в алгоритмі. У таких задачах «порожньо» трапляється часто і не є винятковою ситуацією.
pop() -> T?: коротко й чесно
У цьому розділі ми подивимося, як Optional спрощує код використання і робить його прямолінійним. Тут не потрібно «ловити» помилку, не потрібно моделювати причини — потрібно просто обробити відсутність значення. Це особливо зручно, коли ви працюєте в циклі або пишете невелику утиліту, а не публічну бібліотеку.
var stack = Stack<Int>()
stack.push(10)
let a = stack.pop()
print(a as Any) // Optional(10)
let b = stack.pop()
print(b as Any) // nil
Зверніть увагу: другий pop() не «ламає програму», не кидає помилку і не вимагає do/catch. Він просто повідомляє: «нічого не знято».
peek: T?: перегляд вершини без змін
peek (або top) за змістом ще більше схиляється до Optional: ми просто перевіряємо, чи є щось угорі. Відсутність елемента — цілком нормальна річ. У підсумку peek із типом T? читається природно.
var stack = Stack<String>()
print(stack.peek as Any) // nil
stack.push("A")
stack.push("B")
print(stack.peek as Any) // Optional("B")
Як розкривати Optional: if let і guard let
Дуже важливо, щоб Optional-контракт не перетворювався на «а, ну тут просто поставимо ! і поїхали». Якщо ви робите stack.pop()!, ви самі скасовуєте контракт і повертаєте собі ризик раптового крешу.
Нормальний стиль виглядає так:
var stack = Stack<Int>()
stack.push(1)
if let top = stack.pop() {
print("Зняли:", top) // Зняли: 1
} else {
print("Стек порожній")
}
А якщо ви всередині функції, то guard let робить код ще спокійнішим:
func printPopped(from stack: inout Stack<Int>) {
guard let value = stack.pop() else {
print("Нема чого знімати") // Нема чого знімати
return
}
print("Зняли:", value)
}
3. throws: «порожньо» як помилка використання
Тепер уявімо інший світ: порожній стек — це не нормальний сценарій, а вада або неправильне використання API. Таке буває, якщо стек є частиною інваріанта вашої системи. Наприклад: «у цій точці алгоритму стек зобов’язаний містити хоча б один елемент». Тоді nil як результат може бути надто «тихим»: ви можете пропустити обробку і втратити причину проблеми.
У throws-контракті ви кажете: «Якщо стека немає — це помилка, і я хочу, щоб ви її обробили явно». Це вже не «чизкейк закінчився», а «ви намагаєтеся оплатити покупку неіснуючою карткою» — потрібно зробити зрозумілу гілку обробки, а не просто повернути nil і сподіватися, що хтось помітить.
Тип помилки: StackError.empty
Почнімо з невеликого типу помилки. Він має бути конкретним і зрозумілим — так API легше читати.
enum StackError: Error {
case empty
}
popOrThrow() throws -> T
Важливо: у Swift стандартна бібліотека теж розрізняє операції, де порожнеча — це «помилка передумови», і операції, де порожнеча — нормальний варіант. Саме тому існують пари на кшталт removeLast() (передумова: колекція не порожня) і popLast() (повертає Optional).
Ми зараз моделюємо схожий підхід, але в «чесному» API-стилі через throws.
struct ThrowingStack<T> {
private var items: [T] = []
mutating func push(_ value: T) { items.append(value) }
mutating func popOrThrow() throws -> T {
guard let v = items.popLast() else { throw StackError.empty }
return v
}
}
Використання: do/catch
throws змушує користувача писати обробку. Це інколи дратує, але у відповідальних місцях це не вада, а перевага.
var stack = ThrowingStack<Int>()
do {
let v = try stack.popOrThrow()
print("Зняли:", v)
} catch StackError.empty {
print("Помилка: стек порожній") // Помилка: стек порожній
} catch {
print("Інша помилка")
}
peekOrThrow() throws -> T: коли це доречно
Якщо у вашій логіці «подивитися на вершину» без елемента — це теж помилка, можна зробити версію peek із throws. Але тут треба бути чесними: зазвичай peek — це читання, і «порожньо» часто є нормою. peek із throws доречний, якщо відсутність вершини ламає інваріант бізнес-логіки.
struct ThrowingStack<T> {
private var items: [T] = []
mutating func push(_ value: T) { items.append(value) }
func peekOrThrow() throws -> T {
guard let v = items.last else { throw StackError.empty }
return v
}
}
Чому try? — не заміна Optional-контракту
Іноді новачки думають: «Ага! Я хочу Optional, але в мене throws — отже, зроблю try?». Так можна, але треба розуміти, що try? перетворює помилку на nil, а отже ви втрачаєте причину.
Тобто try? — це зручний інструмент, але він саме згортає помилку у відсутність значення, і це має бути усвідомлене рішення.
4. Result: «порожньо» як значення, з яким зручно жити
Result — це компроміс між «тихим nil» і «обов’язковим do/catch». Він корисний, коли ви хочете передавати результат далі як дані: складати результати, агрегувати їх, повертати з функцій без throws, зберігати у змінних і потім розбирати.
Якщо Optional схожий на «можливо, є, можливо, немає», а throws — на «якщо немає, це виняток, зупиняємося», то Result — це «у мене є об’єкт із двома станами, і я потім вирішу, що робити».
popResult() -> Result<T, StackError>
Зробімо стек, де pop повертає Result. Помилка та сама: StackError.empty. Важливо, що Result зручно читати через switch.
struct ResultStack<T> {
private var items: [T] = []
mutating func push(_ value: T) { items.append(value) }
mutating func popResult() -> Result<T, StackError> {
guard let v = items.popLast() else { return .failure(.empty) }
return .success(v)
}
}
Використання: switch
var stack = ResultStack<Int>()
switch stack.popResult() {
case .success(let v):
print("Зняли:", v)
case .failure(.empty):
print("Стек порожній") // Стек порожній
}
peekResult() і різниця з Optional
peekResult() — це той самий принцип: успіх або помилка як дані.
struct ResultStack<T> {
private var items: [T] = []
mutating func push(_ value: T) { items.append(value) }
func peekResult() -> Result<T, StackError> {
guard let v = items.last else { return .failure(.empty) }
return .success(v)
}
}
Чому це може бути зручніше за Optional? Тому що Optional не дає причини. Result дає вам «гілку помилки» і дозволяє не забути про неї, але при цьому не змушує вмикати throws у весь ланцюжок функцій.
5. Як вибрати контракт для pop/peek
Коли ви починаєте проєктувати API, хочеться «обрати один правильний варіант назавжди». Але в реальності правильний варіант залежить від того, що саме ви обіцяєте користувачеві вашого типу. У цьому розділі ми обережно порівняємо три підходи й спробуємо обрати не «модно», а «за змістом».
Нижче — таблиця, яка допомагає думати не про синтаксис, а про зміст:
| Контракт | Сигнатура pop | Як трактувати порожнечу | Як виглядає використання | Коли це найдоречніше |
|---|---|---|---|---|
|
|
звичайний сценарій | if let, guard let | алгоритми, утиліти, «може бути порожньо» |
|
|
помилка / порушення очікування | do/catch | коли порожнеча — це порушення інваріанта |
|
|
помилка, але як дані | switch, передача далі | коли потрібен «частковий успіх», збір звіту, збереження результату |
Тут корисно згадати ідею зі стандартної бібліотеки: операції «remove» і «pop» відрізняються саме тим, наскільки порожнеча є допустимою, і це відображено в типах результату.
6. Практичний дизайн: один Stack<T> і кілька контрактів
На практиці ви можете зробити один тип Stack<T> і дати кілька методів із явно різними назвами. Це хороший компроміс для навчального проєкту: ви вчитеся проєктувати API і водночас не плодите три різні типи.
Тут головне правило: назви мають говорити про контракт. Якщо у вас є pop() -> T?, то throwing-версія має називатися не просто pop(), а, наприклад, popOrThrow(). Тоді код читається чесно: ви прямо в точці виклику бачите, що тут буде try.
enum StackError: Error { case empty }
struct Stack<T> {
private var items: [T] = []
mutating func push(_ value: T) { items.append(value) }
mutating func pop() -> T? { items.popLast() }
func peek() -> T? { items.last }
mutating func popOrThrow() throws -> T {
guard let v = items.popLast() else { throw StackError.empty }
return v
}
mutating func popResult() -> Result<T, StackError> {
guard let v = items.popLast() else { return .failure(.empty) }
return .success(v)
}
}
Зверніть увагу: peek() тут зроблено як метод, а не як властивість — просто щоб було зручно підтримувати «сімейство» (peek(), peekOrThrow(), peekResult()), якщо ви захочете. Але це вже питання смаку й стилю.
Міні-демо: інтерактивна консоль «Stack Playground»
Тепер зробімо маленьку консольну іграшку, щоб відчути різницю контрактів на практиці. Ми не будуємо великої інфраструктури: просто читаємо команди й виконуємо push, pop, peek, а також варіанти pop! (throws) і pop? (Result). Це трохи кумедно, зате швидко показує, як змінюється стиль коду.
import Foundation
enum StackError: Error { case empty }
struct Stack<T> {
private var items: [T] = []
mutating func push(_ value: T) { items.append(value) }
mutating func pop() -> T? { items.popLast() }
mutating func popOrThrow() throws -> T {
guard let v = items.popLast() else { throw StackError.empty }
return v
}
mutating func popResult() -> Result<T, StackError> {
guard let v = items.popLast() else { return .failure(.empty) }
return .success(v)
}
func peek() -> T? { items.last }
}
Тепер цикл команд (зробімо тільки Int, щоб не ускладнювати парсинг):
import Foundation
var stack = Stack<Int>()
while let line = readLine() {
if line == "exit" { break }
if line.hasPrefix("push ") {
let valueText = String(line.dropFirst(5))
if let value = Int(valueText) {
stack.push(value)
print("ок") // ок
} else {
print("погане число") // погане число
}
continue
}
if line == "pop" {
print(stack.pop() as Any) // Optional(...) або nil
continue
}
if line == "peek" {
print(stack.peek() as Any) // Optional(...) або nil
continue
}
if line == "pop!" {
do {
let v = try stack.popOrThrow()
print(v) // число
} catch StackError.empty {
print("помилка: порожньо") // помилка: порожньо
} catch {
print("помилка") // помилка
}
continue
}
if line == "pop?" {
switch stack.popResult() {
case .success(let v): print(v) // число
case .failure(.empty): print("порожньо") // порожньо
}
continue
}
print("невідома команда") // невідома команда
}
Ця мініпрограма показує важливу річ: контракт API безпосередньо диктує стиль коду навколо нього. Optional веде до if let, throws веде до do/catch, Result веде до switch. Не можна обрати контракт лише для того, щоб «було коротше», бо «коротше» в одному місці може зробити гірше в іншому.
7. Типові помилки
Помилка №1: робити pop() -> T і «вирішувати» порожнечу через !.
Такий дизайн майже завжди закінчується раптовими крешами. Якщо порожнеча можлива — вона має бути відображена в типі результату. Інакше ваш стек стає «бомбою з таймером»: він компілюється і навіть працює… доки не перестає.
Помилка №2: змішувати контракти без зрозумілих назв.
Якщо у вас є і Optional-версія, і throwing-версія, і Result-версія, але вони називаються схоже або однаково, читання коду перетворюється на детектив. Назви мають підказувати контракт прямо в точці виклику: pop(), popOrThrow(), popResult() — хороший, чесний набір.
Помилка №3: використовувати removeLast() замість popLast() і випадково отримати креш.
Якщо внутрішня реалізація стека спирається на масив, то removeLast() вимагає, щоб масив був непорожнім, інакше це порушення передумови. popLast() повертає Optional і не падає, тому він природно підтримує Optional-контракт для pop. Це саме та різниця, через яку в стандартній бібліотеці існують обидві операції.
Помилка №4: перетворювати throws на «Optional із втратою інформації» через try? без потреби.
try? справді робить код коротшим, але воно стирає причину помилки. Це нормально, якщо вам справді не важливо, чому не вдалося, але дуже погано, якщо ви робите це просто тому, що не хочеться писати do/catch.
Помилка №5: повертати занадто загальний Result<T, Error> замість конкретної помилки.
Коли помилка розмивається до Error, користувач API втрачає підказку, що саме може піти не так. Для стека логічно мати StackError.empty. Чим точніший контракт, тим легше читати й тестувати код.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ