1. Производительность зависит от сценария
Когда новичок слышит «надо ускорить программу», он часто представляет себе что‑то вроде: «Наверное, Swift медленный… или надо переписать всё на C… или включить турбо‑режим компилятора, и всё полетит». Звучит драматично, но реальность обычно скучнее и приятнее: производительность почти всегда упирается в пару конкретных мест, которые выполняются слишком часто или делают слишком много лишней работы.
В CLI это особенно заметно: часть времени уходит на чтение/запись файла, часть — на парсинг команд, часть — на поиск по библиотеке, часть — на кодирование/декодирование JSON, часть — на сеть. И если вам «медленно», то первый вопрос: какая конкретно команда медленная и на каких данных? «У меня медленно» — это диагноз уровня «компьютер грустит».
Чтобы разговаривать на языке фактов, нам понадобятся два понятия: baseline (базовая линия) и bottleneck (узкое место). И да, это те самые умные слова, которыми приятно пугать коллег, но сегодня мы будем использовать их по назначению.
Baseline: честное сравнение «до/после»
Baseline — это ваш эталон «как сейчас». Он нужен, потому что человеческий мозг отлично умеет убеждать себя в улучшениях без доказательств. Особенно после второго кофе. Особенно если вы только что написали «красивый» рефакторинг и вам хочется верить, что он ещё и ускорил программу.
Baseline всегда состоит из трёх вещей: фиксированный сценарий, фиксированный вход и фиксированный способ измерения. Если вы поменяли входные данные между «до» и «после», вы сравнили не изменения в коде, а две разные задачи. Если вы измерили один раз, вы сравнили не скорость, а настроение ОС, антивируса и планировщика.
В контексте LibraryCLI baseline можно формулировать, например, так: «Команда search по 50 000 книгам, запрос из 3 токенов, поиск по title+authors, без печати каждой найденной книги (только количество)». Тут уже есть конкретика: объём данных, структура запроса, ожидаемая работа.
2. Измеряем время без профайлера
Сейчас нам не нужен IDE‑профайлер. Нам нужен «грубый таймер», который покажет порядки величин: стало в 2 раза быстрее, в 10 раз быстрее или вообще не изменилось. Для такого уровня честности Date достаточно: он не идеален для микросекунд, но отлично подходит для «в этой версии поиск занимает ~0.9 сек, а в той ~0.12 сек».
Минимальный измеритель
import Foundation
func measure(_ label: String, _ work: () -> Void) {
let start = Date()
work()
let elapsed = Date().timeIntervalSince(start)
print("\(label): \(elapsed)s")
}
Пара нюансов здесь важнее самого кода. Во‑первых, внутри work() не должно быть print в цикле, потому что консольный вывод — это I/O, а I/O медленный «по определению». Во‑вторых, один прогон почти ничего не доказывает: даже для грубой оценки лучше прогнать несколько раз.
Пример «просто цикл»
import Foundation
measure("Loop 1M") {
var sum = 0
for i in 0..<1_000_000 {
sum += i
}
_ = sum
}
// Loop 1M: 0.00...s (цифра будет зависеть от машины)
Вывод рядом в комментарии — это, конечно, условность: на вашей машине будут другие числа, но сам подход остаётся тем же.
4. Узкие места: ищем bottleneck по фактам
Когда программа «медленная», причина обычно в том, что какой‑то участок определяет общее время. Это и есть bottleneck: даже если вы ускорите всё остальное, пока узкое место не тронуто — общая картина почти не меняется.
В LibraryCLI типичный bottleneck выглядит так: пользователь делает search, а мы каждый раз линейно пробегаем по всем книгам и для каждой делаем дорогое преобразование строки, токенизацию, нормализацию. Если книг 50 000, а запросов пользователь делает 30, то вы внезапно «прожигаете» 1.5 млн проверок… и это ещё оптимистичный сценарий.
Ниже — очень упрощённая схема, где чаще всего живут bottleneck’и в нашем проекте:
flowchart TD
A[Команда CLI] --> B[Парсинг аргументов]
B --> C[Загрузка данных файл/JSON]
C --> D[Поиск/индексация]
D --> E[Вывод результата]
C --> F[Сеть fetch]
Обратите внимание: I/O‑штуки (файл, сеть, вывод) могут «съесть» почти всё время. Тогда оптимизация вычислений (циклов и коллекций) даст смешной эффект. Поэтому первое правило: отделяйте в голове вычисления от I/O, а в измерениях — тем более.
5. Три класса проблем: алгоритм, константы, I/O
Производительность удобно раскладывать на три «ящика». Это помогает не путаться и не оптимизировать не то.
| Класс | Как выглядит в коде | Типичный симптом | Что делать |
|---|---|---|---|
| Алгоритмика | O(n²) вместо O(n), вложенные циклы | резко хуже при росте данных | менять подход (индекс, хеш, сортировка+поиск) |
| Константы | лишние копии, лишние промежуточные массивы | «как будто всё O(n), но всё равно медленно» | убирать лишние аллокации/копии, аккуратнее с pipeline |
| I/O | файл/сеть/консоль | «медленно всегда, даже на маленьких данных» | уменьшать число операций I/O, буферизовать, не печатать лишнее |
Сегодня мы в основном про второй ящик (константы) и про дисциплину измерений, но алгоритмику тоже будем держать рядом, потому что иногда самый быстрый способ ускорить код — перестать делать лишнюю работу вообще.
6. Лишние копии и лишняя работа в Swift
Почему копии появляются и почему это не «плохой Swift»
Swift любит value semantics: Array, Dictionary, String — это value types. Наивное понимание звучит страшно: «Если это value, значит всё копируется при каждом присваивании?!» Если бы так было, Swift умер бы в младенчестве, не дожив до версии 1.0.
На практике используется Copy‑on‑Write: значение может быть логически копией, но физически память разделяется, пока вы не попытаетесь мутировать.
Но важный момент: COW — это не «бесплатно всегда». Это «бесплатно, пока вы не мутируете». Если вы случайно заставили коллекцию мутировать там, где не планировали, то копия случится в самый неожиданный момент: код выглядит безобидно, а под капотом выделяется новая память и копируются элементы.
Простейший пример «как случайно делать лишнюю работу» — это построение нового массива через многократные конкатенации:
func slowBuild(_ n: Int) -> [Int] {
var result: [Int] = []
for i in 0..<n {
result = result + [i] // каждый раз создаём новый массив
}
return result
}
Этот код компилируется, работает… и при больших n становится печальным. Потому что result + [i] создаёт новый массив, копирует старое содержимое, добавляет элемент — и так n раз. Это уже ближе к «алгоритмике» по эффекту: вы сами себе построили «почти O(n²)» на ровном месте.
reserveCapacity: меньше аллокаций при росте коллекции
Когда вы делаете append, массив растёт. Чтобы рост не был катастрофой, он растёт «пачками»: выделяет больше памяти, чем нужно прямо сейчас, чтобы реже переаллоцировать буфер. Но если вы заранее знаете примерный размер — можно помочь массиву (или словарю) и снизить число перераспределений.
Для Dictionary и Set идея та же: заранее выделить ёмкость под ожидаемое количество элементов. В стандартной библиотеке это считается важной оптимизациией, потому что перераспределение и перенос элементов для хеш‑коллекций — операция дорогая.
Вот аккуратный вариант для массива:
func buildIDs(_ n: Int) -> [Int] {
var result: [Int] = []
result.reserveCapacity(n)
for i in 0..<n {
result.append(i)
}
return result
}
Обратите внимание на психологический момент: reserveCapacity — это не «магическое ускорение». Это просто уменьшение количества аллокаций и копирований при росте буфера. Поэтому это относится к «константам»: выигрыш бывает заметен на больших данных, а на маленьких — почти никакой.
reduce(into:): собираем результат без лишних копий
Мы уже использовали reduce как инструмент «свернуть коллекцию в одно значение». Но у reduce есть тонкость: если ваш аккумулятор — сложная структура (например, словарь), то наивный reduce может вынуждать создавать новые значения чаще, чем нужно.
Поэтому существует форма reduce(into:): она даёт вам аккумулятор как inout, и вы изменяете его «на месте».
Давайте представим, что мы токенизируем запрос пользователя и хотим посчитать частоты слов (не потому что нам очень надо, а потому что это классический пример «агрегации»):
let words = ["swift", "cli", "swift", "library"]
let counts = words.reduce(into: [String: Int]()) { acc, w in
acc[w, default: 0] += 1
}
print(counts) // ["swift": 2, "cli": 1, "library": 1]
Здесь важна интонация: мы не «оптимизируем ради оптимизации», мы выбираем API, который прямо выражает намерение «собираю результат» и при этом не провоцирует лишние копии накопителя.
lazy: когда pipeline перестаёт строить промежуточные коллекции
Цепочки .filter { ... }.map { ... } читаются приятно, но у них есть цена: каждая операция по умолчанию создаёт новую коллекцию. Иногда это нормально и даже желательнее с точки зрения ясности. Но иногда вы учитываете только «первые несколько элементов» или делаете first(where:), и тогда создавать весь промежуточный массив — странно.
Ленивые коллекции позволяют «не делать работу, пока результат не потребовали».
Мини‑пример, который похож на реальность LibraryCLI: мы берём большой список ID, фильтруем, преобразуем и берём только первый элемент.
let numbers = Array(0..<1_000_000)
let firstSquare = numbers.lazy
.filter { $0.isMultiple(of: 2) }
.map { $0 * $0 }
.first
print(firstSquare as Any) // Optional(0)
Если бы мы не использовали lazy, то filter создал бы массив из всех чётных чисел, затем map создал бы массив квадратов, и только потом мы взяли бы первый. С lazy большая часть работы просто не выполняется.
Но у lazy есть характер: если вы всё равно «потребляете» всю коллекцию (например, делаете Array(...) в конце), то выигрыш может быть небольшим или нулевым. И ещё важнее: если lazy делает код менее понятным для вашей команды — это плохая оптимизация.
7. Мини‑кейс: наивный поиск и индекс в LibraryCLI
Сейчас сделаем очень учебный, но полезный мысленный эксперимент. Пусть у нас есть книги:
struct Book {
let id: Int
let title: String
}
И наивный поиск «в лоб»:
func naiveSearch(_ books: [Book], query: String) -> [Book] {
books.filter { $0.title.localizedCaseInsensitiveContains(query) }
}
Это O(n) по количеству книг. Если книг мало — всё хорошо. Но если книг десятки тысяч и запросов много — bottleneck становится очевиден.
Индексированный вариант мы обсуждали раньше: строим структуру «токен → множество ID». Сегодня нам важна не архитектура индекса (она уже была), а производительность его построения и использования.
Упрощённый пример «строим индекс»:
func buildIndex(_ books: [Book]) -> [String: [Int]] {
var index: [String: [Int]] = [:]
index.reserveCapacity(books.count)
for b in books {
let key = b.title.lowercased()
index[key, default: []].append(b.id)
}
return index
}
Здесь есть два заметных места, где можно терять скорость на константах. Во‑первых, lowercased() создаёт новую строку; если вы делаете это много раз, это заметно. Во‑вторых, index[key, default: []].append(...) может много раз расширять массив ID для популярных ключей.
Это не значит, что код «плохой». Это значит, что если baseline показывает, что именно здесь вы тратите время, тогда вы начинаете думать: можно ли нормализовать строки иначе, можно ли хранить токены, можно ли переиспользовать результаты, можно ли заранее оценить размеры.
И вот тут дисциплина измерений спасает от религиозных войн. Вы не спорите «lowercased() зло». Вы говорите: «Команда search на 50k книг: 0.92s. После изменения: 0.24s. Код стал на 15 строк длиннее. Оно того стоит?».
8. Как не скатиться в культ оптимизации
У новичка часто бывает такой путь: он узнаёт, что «функции высшего порядка» (map, filter, reduce) — это красиво, и начинает писать всё через цепочки. Потом узнаёт про lazy и начинает писать всё через lazy. Потом узнаёт про reserveCapacity и начинает резервировать ёмкость даже для массива из трёх элементов (потому что «так правильно»).
Хорошая инженерия — это не «всегда самая быстрая версия», а «самая простая версия, которая достаточно быстрая». При этом «достаточно быстрая» — это не ощущение, а измерение.
Кстати, полезный маячок мышления: даже на уровне дизайна стандартной библиотеки приёмы иногда выбирают специально, чтобы не провоцировать лишние COW‑копии. Например, там, где используется inout state, это помогает избегать ненужных копий COW‑структур. Разработчики языка тоже постоянно думают про копии и стоимость, но делают это там, где это реально влияет.
9. Типичные ошибки
Ошибка №1: измерять вместе с print (и потом «оптимизировать» не то).
Консольный вывод — это I/O, а I/O часто на порядки медленнее вычислений. Если вы печатаете внутри цикла, вы измеряете скорость печати, а не скорость алгоритма. Правильный подход — собирать результат в переменную и печатать один раз после измеряемого блока.
Ошибка №2: сравнивать «до/после» на разных входных данных.
Очень легко случайно поменять данные и получить «ускорение» или «замедление», которое на самом деле связано не с кодом, а с тем, что один тест был на 5 000 книг, а другой — на 50 000. Baseline ценен именно тем, что вход фиксирован, и вы сравниваете яблоки с яблоками, а не яблоки с кофе‑брейком.
Ошибка №3: делать вывод по одному прогону.
Даже грубый тайминг может плавать. Фоновые процессы, кэш, прогрев JIT (в Swift его нет, но есть кэш файловой системы, кэш DNS, кэш диска), планировщик — всё это влияет. Если разница между вариантами маленькая, один прогон почти ничего не значит. Лучше сделать 5–10 прогонов и смотреть хотя бы на «порядок величины».
Ошибка №4: «оптимизировать» красивый код без доказанного bottleneck.
Самая дорогая оптимизация — та, которая сделала код сложнее, но не дала заметного выигрыша. Вы платите сложностью каждый день, а выигрыш получаете никогда. Поэтому сначала ищем узкое место, и только потом усложняем, если это действительно уменьшает время.
Ошибка №5: путать улучшение алгоритма и улучшение констант.
reserveCapacity, reduce(into:), lazy — это про константы, то есть про уменьшение лишних копий/аллокаций при той же асимптотике. Если проблема в том, что вы сделали O(n²) вместо O(n), то «подкрутить константы» редко спасает. В такие моменты нужно не «прикручивать lazy», а менять подход: индекс, хеш‑структуры, предварительная подготовка данных.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ