JavaRush /Курси /Swift SELF /Міні-визначення «протокол» та Sequence проти Collection

Міні-визначення «протокол» та Sequence проти Collection

Swift SELF
Рівень 18 , Лекція 0
Відкрита

1. Протокол як «контракт»

Якщо слово «протокол» звучить як щось зі світу медицини («протокол лікування»), ви не самі. Але в програмуванні все простіше й цікавіше: протокол — це контракт, тобто список вимог до типу. І вже сьогодні нам достатньо такого міні-визначення: хто виконує контракт, той отримує право користуватися певними можливостями мови.

Уявіть, що ви прийшли в аеропорт. Вам не потрібно знати будову літака, щоб пройти контроль. Достатньо виконати контракт: показати паспорт і квиток. З типами у Swift схожа історія. Тип не зобовʼязаний бути «масивом», щоб його можна було перебирати. Він має відповідати контракту перебору — і тоді for-in скаже: «О, ви свій, проходьте».

У стандартній бібліотеці Swift є чимало протоколів, але сьогодні ми зосередимося на двох:

  • Sequence — контракт «можна перебирати елементи один за одним».
  • Collection — сильніший контракт: «можна перебирати + є індекси та доступ за індексом».

Важливо: ми поки що не вчимося писати власні протоколи й не заглиблюємося в синтаксис оголошення protocol та реалізації відповідності йому. Нам достатньо навчитися читати код і розуміти, чому один і той самий стиль перебору працює на різних структурах даних.

2. Sequence: «це можна перебрати»

Зараз буде момент «а ось чому воно взагалі працює». Раніше легко було думати так: «for-in — це особливий цикл для масивів». Але Swift робить це витонченіше й красивіше: for-in працює з будь-яким типом, який відповідає контракту Sequence. Тобто, грубо кажучи: «дайте мені елементи по одному — і я впораюся».

Почнемо з дуже знайомого: масив чисел.

import Foundation

let numbers = [10, 20, 30]

for n in numbers {
    print("n =", n) // спочатку 10, потім 20, потім 30
}

Тут елементом перебору є Int. Усе логічно.

Тепер рядок. Ми вже знаємо, що рядок — це не «масив символів з індексами Int», але перебирати його можна:

import Foundation

let word = "Swift"

for ch in word {
    print(ch) // S, w, i, f, t
}

Тут елемент перебору — Character. Це теж логічно: рядок «повертає» не байти, а символи (графеми).

Тепер словник. Тут у новачків часто виникає паніка: «А що я взагалі перебираю — ключі чи значення?». Відповідь: пари.

import Foundation

let scores = ["Ann": 10, "Bob": 7]

for (name, score) in scores {
    print("\(name): \(score)") // порядок може бути будь-яким
}

Елемент під час перебору словника — кортеж (key, value). Його зручно розпакувати прямо в заголовку циклу.

І ще один приємний сюрприз: діапазони теж перебираються.

import Foundation

let r = 1...3

for x in r {
    print(x) // 1, 2, 3
}

Тобто for-in — не «цикл для масиву». Це «цикл для Sequence».

Маленька, але важлива думка про порядок

У Array порядок стабільний: він зберігає елементи по черзі. А в Set і Dictionary порядок не гарантований: сьогодні буде так, завтра інакше, а після оновлення Swift — ще один варіант. Тому умовний код «візьми перший елемент із Set і вважай, що він найважливіший» — це як обирати начальника відділу за принципом «хто першим зайшов у ліфт».

Що дає Sequence на практиці

Sequence корисний тим, що на ньому працює багато знайомих вам операцій: map, filter, reduce, split тощо. А ще існують «ліниві» (lazy) варіанти деяких операцій, які виконують роботу лише під час використання результату, а не одразу. Цю ідею офіційно підтримує стандартна бібліотека, і вона буде важливою в наступних лекціях.

3. Collection: індекси та межі

Слово Collection звучить як «колекція», і це майже правда, але у Swift це ще й технічний контракт. Головна відмінність від Sequence у тому, що Collection гарантує наявність індексів і можливість дістати елемент за індексом: c[index].

І ось тут починається вже серйозна розмова про те, що індекс не зобов’язаний бути Int. Ми це вже бачили на рядках (String.Index), і сьогодні просто фіксуємо: Collection говорить «у мене є індекси», але не говорить «вони цілі числа».

Індексація на прикладі масиву

Масив — класична колекція: його можна перебирати й індексувати.

import Foundation

let numbers = [10, 20, 30]

print(numbers[0]) // 10
print(numbers[1]) // 20

Але ви вже знаєте небезпеку: індекс може вийти за межі, і програма аварійно завершиться. Тому безпечний підхід — перевіряти, що індекс валідний.

import Foundation

let numbers = [10, 20, 30]
let i = 5

if numbers.indices.contains(i) {
    print(numbers[i])
} else {
    print("Елемента з індексом \(i) немає") // елемента з індексом 5 немає
}

indices — дуже корисна річ: це діапазон усіх допустимих індексів колекції. І це знову мислення через контракт: не «я знаю, що індекси 0..<(count)», а «колекція сама підказує мені, які індекси допустимі».

startIndex і endIndex

У будь-якої Collection є дві фундаментальні межі: startIndex і endIndex. І ось тут багато хто спотикається: endIndex — це позиція за останнім елементом, а не «останній індекс».

Перевіримо на масиві:

import Foundation

let numbers = [10, 20, 30]

print(numbers.startIndex) // 0
print(numbers.endIndex)   // 3

У масиву з трьох елементів останній валідний індекс — 2, але endIndex дорівнює 3, тому що він «за кінцем».

Міні-нагадування: індекси рядків

Ми вже бачили, що рядок у Swift не індексується Int. І тепер це стає логічним: рядок — теж Collection, просто його індекс — String.Index.

import Foundation

let text = "Hi"
let i0 = text.startIndex
let i1 = text.index(after: i0)

print(text[i0]) // H
print(text[i1]) // i

Це не «ускладнення заради ускладнення». Це чесна ціна за Unicode та коректну роботу з рядками.

4. Sequence vs Collection: один прохід і доступ за індексом

Коли ви тільки починаєте, легко подумати: «Ну і що, і там, і там я можу зробити for-in». Так, але це як порівняти велосипед і автомобіль словами «і те, і те їде». Різниця стає важливою, коли ви починаєте писати код, який не лише «їде», а ще й має бути безпечним, зрозумілим і не ламатися від дрібниць.

Порівняння в одній таблиці

Властивість/можливість
Sequence
Collection
Можна перебирати в for-in Так Так (бо
Collection
— це
Sequence
)
Є індекси Не гарантує Так (startIndex, endIndex, indices)
Можна взяти елемент за індексом c[i] Не гарантує Так
Індекс обов’язково Int Ні Ні
Чи можна гарантовано «пройтися вдруге» Не гарантує Зазвичай так, і це очікувана поведінка

Розберімо останній пункт, бо саме тут і з’являється різниця між «одним проходом» і доступом за індексом.

Як зрозуміти «один прохід» по-людськи

Зараз буде важливий момент, який спочатку здається філософією, а потім раптово економить вам години налагодження. Sequence обіцяє, що ви можете отримати елементи по одному, але не обіцяє, що ви зможете повторити це тим самим способом. Умовно кажучи, Sequence може бути як конвеєр: деталь пройшла — назад її вже не повернеш.

А Collection більше схожий на полицю в шафі: ви можете знову і знову підходити до неї, брати третій предмет, потім перший, потім знову третій. Є «адреси» (індекси), і доступ до них визначений.

Ось проста схема цієї ідеї:

flowchart LR
    A[Джерело даних] --> B[Sequence: елементи один за одним]
    B --> C[for-in / map / filter]
    C --> D[Використання результату]

Якщо джерело — «потокове» (наприклад, читання рядків із великого файла, генерація значень, читання даних із мережі), то повторний прохід може бути дорогим або взагалі неможливим. Саме тому Sequence не обіцяє багаторазовий обхід: контракт чесний і нічого не приховує.

А ось Collection зазвичай означає «сховище елементів», по якому можна ходити туди-сюди. Воно дає індекси та межі — тобто спосіб адресації.

І це безпосередньо впливає на стиль коду:

  • якщо вам достатньо «пройтися і щось порахувати» — мисліть як Sequence;
  • якщо вам потрібно «взяти конкретний елемент, замінити, вставити, безпечно звернутися за позицією» — вам потрібен Collection (або принаймні щось з індексами).

5. Міні-приклад: «книжкова полиця»

Зараз зберемо невеликий приклад, який не вимагає struct (їх ми розбиратимемо пізніше), але вже схожий на фрагмент справжнього застосунку: у нас є невелика бібліотека книжок, ми друкуємо список, шукаємо найстарішу книжку й безпечно дістаємо книжку за індексом.

Дані зберігатимемо як масив кортежів (title: String, year: Int) — кортежі ви вже бачили, тож це проста й зручна структура до появи struct.

Друк списку книжок: нам достатньо Sequence

Ми хочемо просто пройтися по всіх книжках і вивести їх. Це чиста логіка перебору.

import Foundation

let books: [(title: String, year: Int)] = [
    ("Clean Code", 2008),
    ("The Pragmatic Programmer", 1999),
    ("Swift Programming", 2021)
]

for book in books {
    print("\(book.title) (\(book.year))")
}
// Clean Code (2008)
// The Pragmatic Programmer (1999)
// Swift Programming (2021)

Тут books — це Array, а масив є Collection. Але в цьому фрагменті нам важлива лише властивість «можна перебрати», тобто поведінка рівня Sequence.

Пошук найстарішої книжки: знову «один прохід»

Задача — знайти мінімальний рік. Розв’язуємо класичним способом: тримаємо поточний мінімум.

import Foundation

let books: [(title: String, year: Int)] = [
    ("Clean Code", 2008),
    ("The Pragmatic Programmer", 1999),
    ("Swift Programming", 2021)
]

var oldestYear = Int.max
for b in books {
    if b.year < oldestYear { oldestYear = b.year }
}
print("Найстаріша книжка: \(oldestYear)") // Найстаріша книжка: 1999

Знову ж таки: нам не потрібні індекси. Нам потрібно «пройтися й агрегувати».

Доступ до конкретної книжки: де допомагає Collection

Тепер хочемо: «візьми книжку за номером, який ввів користувач». Це вже історія про індекси та межі.

import Foundation

let books: [(title: String, year: Int)] = [
    ("Clean Code", 2008),
    ("The Pragmatic Programmer", 1999),
    ("Swift Programming", 2021)
]

let index = 1
if books.indices.contains(index) {
    let book = books[index]
    print("Ви вибрали: \(book.title)") // Ви вибрали: The Pragmatic Programmer
}

Тут ключове — indices.contains(index). Ми не вгадуємо межі, ми запитуємо у колекції: «цей індекс валідний?».

6. Чому map/filter/reduce працюють не лише на масивах

Дуже поширена серед новачків думка: «map — це метод масиву». Насправді ви вже майже готові до правильного формулювання: багато таких методів визначені на рівні Sequence. Тобто логіка така: якщо тебе можна перебирати, я зможу пройтися по твоїх елементах і побудувати результат.

Подивімося на Set. Порядок нам не важливий, важлива сама ідея «до всіх застосувати перетворення».

import Foundation

let ids: Set<Int> = [3, 1, 2]

let strings = ids.map { "id=\($0)" }
print(strings) // порядок у масиві може бути будь-яким

map поверне масив (це нормально), але сам факт, що Set підтримує map, пояснюється контрактом, який дозволяє перебір.

Із словником усе так само, тільки елемент — пара (key, value):

import Foundation

let scores = ["Ann": 10, "Bob": 7]

let lines = scores.map { pair in
    "\(pair.key)=\(pair.value)"
}

print(lines) // порядок не фіксований

І тут знову потрібна обережність: якщо ви потім будете порівнювати рядки «за порядком», словник може вас підставити. Не зі зла — просто він нічого не обіцяв.

7. Типові помилки

Помилка №1: вважати, що for-in — «цикл для масиву».
Через це людина починає писати зайві костилі: спочатку все зводить до [T], а потім уже перебирає. Насправді for-in — це стиль роботи з Sequence, і половина стандартних типів уже вміє бути послідовністю без ваших додаткових зусиль. Виграш у тому, що код стає універсальнішим і простішим.

Помилка №2: плутати перебір та індексацію.
Новачок бачить, що по рядку можна пройтися for ch in text, і робить висновок: «значить, я можу й text[3]». Але for-in вимагає лише Sequence, а text[3] — це вже очікування індексної моделі Collection (і навіть усередині Collection індекс не зобов’язаний бути Int). У підсумку виникає роздратування: «чому Swift такий шкідливий», хоча Swift просто не дає вам помилитися в Unicode-реальності.

Помилка №3: сприймати endIndex як «останній індекс».
Це одна з найчастіших логічних помилок. endIndex — це позиція після останнього елемента, тому collection[endIndex] — завжди помилка. Якщо тримати в голові модель «напіввідкритих діапазонів» і фразу «endIndex — за кінцем», ця проблема зникає майже назавжди.

Помилка №4: будувати алгоритм на порядку Set/Dictionary.
Сьогодні код «випадково працює», завтра — ні. Це особливо підступно: він може працювати 100 разів поспіль, а на 101-му ви отримаєте інший порядок і інший результат. Рішення просте: або ви явно сортуєте дані перед тим, як спиратися на порядок, або обираєте структуру даних, яка цей порядок гарантує (наприклад, масив).

Помилка №5: думати, що якщо тип «перебирається», то він точно «багаторазовий».
Інтуїтивно хочеться вважати, що «ну я ж можу вдруге зробити for-in». Для багатьох колекцій це так, але контракт Sequence у загальному випадку цього не гарантує: деякі послідовності за своєю природою одноразові (конвеєр, потік, генерація). Тому корисно заздалегідь розрізняти: «мені потрібен просто прохід» чи «мені потрібен доступ за індексом / повторюваність».

1
Задача
Swift SELF, 18 рівень, 0 лекція
Недоступна
Друк послання
Друк послання
1
Задача
Swift SELF, 18 рівень, 0 лекція
Недоступна
Унікальна виручка
Унікальна виручка
1
Задача
Swift SELF, 18 рівень, 0 лекція
Недоступна
Безпечний індекс
Безпечний індекс
1
Задача
Swift SELF, 18 рівень, 0 лекція
Недоступна
Символ за порядком
Символ за порядком
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ