1. Навіщо LLDB і де він живе
Якщо ви тільки починаєте, може здатися, що налагодження — це просто «додав print, подивився, видалив print». І справді, це працює… доти, доки у вас одне значення й один сценарій. Та в реальній програмі значення змінюються, умови розгалужуються, код викликається з різних місць, а помилки люблять ховатися в найнезручніших кутах. Тоді print() починає поводитися як ліхтарик, що світить лише туди, куди ви заздалегідь здогадалися подивитися, — а ви, як на зло, здогадалися неправильно.
LLDB — це налагоджувач, який дає змогу зупинити програму, побачити значення змінних «у моменті», виконати невеликі вирази й потім продовжити виконання. В офіційних матеріалах Swift це описують досить прямо: запустити програму під LLDB, поставити брейкпоінт, виконати p для перегляду значення і, якщо треба, подивитися стек викликів через bt.
Уявіть, що ваш код — це детектив, а LLDB — це можливість поставити сюжет на паузу й запитати персонажів: «А ти де був учора ввечері й з яким значенням tokens.count ти сюди прийшов?».
IDE-консоль і термінальний LLDB
Коли ви запускаєте проєкт у IDE, під капотом зазвичай стартує той самий LLDB, просто у вас є два «входи» в один і той самий будинок: кнопки (Step Over/Into/Out) і консоль. Це зручно: ви можете крокувати кодом мишею й одночасно вводити команди. Але важливо розуміти просте правило: LLDB-команди працюють лише тоді, коли програма зупинена — на брейкпоінті або під час падіння.
Якщо програма «біжить», LLDB не може прочитати значення локальних змінних, бо в цей момент код виконується. Тому типовий ритм такий: поставили брейкпоінт → запустили → програма зупинилася → дивимося значення → продовжуємо виконання.
У термінальному сценарії, коли ви запускаєте lldb вручну, це виглядає як окрема «міні-оболонка», де ви вводите команди на кшталт b, r, p, bt. У гайді Swift це показують буквально на прикладі factorial: ставимо брейкпоінт за рядком, запускаємо командою run, друкуємо параметр n через p n, а потім можна подивитися ланцюжок викликів bt.
Навіть якщо ви завжди працюєте в IDE, корисно сприймати LLDB-консоль як «панель керування програмою».
2. Навчальний приклад: парсер команд
Код із навмисним багом
Щоб не пояснювати LLDB «у вакуумі», будемо налагоджувати крихітний шматок логіки в стилі нашого CLI-проєкту (умовно назвемо його LibraryCLI). Нехай він читає команди користувача з рядка й намагається розібрати прості команди add і list.
Нижче код навмисно написаний із невдалими рішеннями, щоб було що налагоджувати. Він компілюється, але за деяких вводів поводиться неприємно.
import Foundation
enum Command {
case add(title: String, year: Int)
case list
}
func parseCommand(_ line: String) -> Command? {
let tokens = line.split(separator: " ")
guard let first = tokens.first else { return nil }
if first == "list" { return .list }
if first == "add" {
let title = String(tokens[1]) // можливий збій
let year = Int(tokens[2]) ?? 0 // маскуємо помилку
return .add(title: title, year: year)
}
return nil
}
Ввід add Dune 1965 ніби правильний, а от add Dune — гарантований кандидат на Index out of range. І тут LLDB дуже швидко допомагає зрозуміти: які саме токени прийшли, скільки їх і чому ми звернулися до tokens[2].
Що будемо перевіряти в налагоджувачі
На цьому прикладі зручно тренувати базову «налагоджувальну гігієну»:
- подивитися вихідний рядок line
- подивитися tokens і tokens.count
- перевірити, яка команда розпізналася в first
- і лише потім робити висновки про причину падіння
3. Перегляд значень: p і po
p і вирази
Команда p (print) — перша, яку зазвичай вивчають, бо вона закриває 80 % побутових завдань налагодження: подивитися значення змінної, перевірити індекс, переконатися, що умова справді істинна, а не «лише здається такою».
Уявіть, що ви поставили брейкпоінт на рядку:
let tokens = line.split(separator: " ") // брейкпоінт тут
Тоді в LLDB ви можете зробити приблизно такі перевірки, як у консолі налагоджувача:
(lldb) p line
(lldb) p tokens
(lldb) p tokens.count
Що зручно: p уміє друкувати не лише змінні, а й прості вирази. У гайді Swift це показано на прикладі p n * n, де n — параметр функції. У нашому випадку це може виглядати так:
(lldb) p tokens.count >= 3
(lldb) p tokens.indices
Дуже практичний підхід — одразу перевіряти «захисні умови», які ви хотіли б мати в коді. Якщо ви бачите, що tokens.count дорівнює 2, а далі звертаєтеся до tokens[2], то причина майбутнього падіння вже не загадка.
po і зручне представлення обʼєктів
po історично читається як «print object». У старих звичках розробників це часто означає: «p друкує сухо, а po — красиво». У сучасному LLDB po теж пов’язаний із розумнішим механізмом друку й уміє виводити Swift-обʼєкти зручніше.
На практиці різниця відчувається так: po часто виводить рядки, колекції й обʼєкти так, як ви хотіли б бачити їх очима, а не так, як їх типізує компілятор.
Наприклад, на нашому брейкпоінті:
(lldb) po tokens
Ви, найімовірніше, побачите щось на кшталт масиву токенів. Залежно від IDE чи платформи формат може відрізнятися, але ідея лишається тією самою: ви швидко розумієте, що реально лежить у tokens.
А ще po корисний, коли ви налагоджуєте свої типи і хочете, щоб вони красиво друкувалися. У подальших темах ви глибше проєктуватимете типи й діагностику, але навіть зараз можна запам’ятати таку думку: якщо тип має зручний description/debugDescription, то й у налагоджувачі жити приємніше.
Нюанс про Swift 5.9+: dwim-print і поведінку друку
Є важливий нюанс: у сучасних версіях Swift/LLDB команда p і po стала значно простішою й швидшою. Починаючи зі Swift 5.9, p і po перевели на механізм dwim-print («Do What I Mean»), який намагається друкувати значення максимально дружньо й без зайвої важкої магії. Це означає менше гальм і менше неочікуваних побічних ефектів.
І ще один момент: починаючи зі Swift 5.9, команда p перестала за замовчуванням створювати так звані «persistent result variables» (на кшталт $R0), які раніше могли утримувати обʼєкти й неочікувано впливати на поведінку або пам’ять під час налагодження. Для вас це означає просту річ: «друкувати значення стало безпечніше й швидше», але звичка думати все одно потрібна — особливо якщо ви починаєте виконувати в налагоджувачі складні вирази.
Окремо варто відзначити цікавий факт: починаючи зі Swift 5.9, po навчився друкувати Swift-обʼєкти навіть тоді, коли ви даєте йому «сиру адресу» обʼєкта — LLDB намагається коректно інтерпретувати адресу як обʼєкт. Новачкові це рідко потрібно щодня, але корисно знати, що налагоджувач став розумнішим і часом виручає в дивних ситуаціях.
4. Контекст виконання: scope, bt і frames
Чому LLDB пише “cannot find … in scope”
Одна з найбільш збивальних із пантелику ситуацій: ви зупинилися на брейкпоінті, бачите рядок коду, впевнені, що змінна «десь поруч», а LLDB відповідає: «не можу знайти таку змінну в області видимості».
Тут важливо розуміти: у змінних є область видимості (scope). Якщо змінну оголошено після поточного рядка, вона ще не існує в термінах виконання програми, і налагоджувач не зобов’язаний показувати її значення. Ба більше, сучасні версії Swift стали точнішими в цьому питанні: у матеріалах про покращення налагодження описують приклад, де змінна a недоступна для p a до того моменту, поки виконання не пройде рядок присвоєння.
Короткий приклад, на якому це добре видно:
import Foundation
func demoScope() {
let x = 10
let y = x + 5 // брейкпоінт тут
print(y) // 15
}
На брейкпоінті p x працюватиме, а от якщо ви спробуєте запитати значення змінної, яка з’явиться нижче, — налагоджувач чесно скаже, що її ще немає. Це не примха, а спосіб не показувати сміття з неініціалізованої пам’яті.
Практичний висновок простий: якщо вам потрібно подивитися значення змінної, переконайтеся, що ви зупинилися після її ініціалізації. Іноді достатньо зробити один Step Over, і змінна «з’явиться».
bt і вибір правильного stack frame
Дуже поширена помилка новачка під час налагодження: ви зупинилися на брейкпоінті, друкуєте p tokens, а LLDB показує щось дивне або не показує зовсім, і ви робите висновки… хоча насправді перебуваєте не в тому кадрі стека.
Стек викликів (call stack) — це ланцюжок функцій, які привели програму до поточної точки. У термінології LLDB кожен виклик функції — це frame, і в кожного frame свої локальні змінні. Команда bt (backtrace) показує стек. У гайді Swift цей крок іде одразу після базового p і теж вважається стандартною гігієною налагодження.
Уявіть це так:
flowchart TD
A[основний цикл зчитує рядок] --> B["parseCommand(line)"]
B --> C[усередині parseCommand: tokens, first]
C --> D[створення Command]
Якщо ви зупинилися всередині parseCommand, то tokens існує. Якщо ви зупинилися вище по стеку, у місці, де parseCommand уже повернув результат, tokens може не існувати — і це нормально.
Коли ви бачите стек через bt, ваша мета — знайти той frame, де живуть потрібні змінні. В IDE ви зазвичай клацаєте по потрібному frame в панелі call stack. У консолі LLDB теж є команди для перемикання frame, але важливіше зараз сама звичка: перед тим як довіряти значенням, переконайтеся, що ви в правильному кадрі.
5. Спостереження: Watches і watchpoints
Watches в IDE
Іноді p/po працюють добре, але ви ловите себе на повторюваній ситуації: на кожній зупинці ви знову друкуєте одні й ті самі речі — tokens.count, books.count, command, year. У цей момент хочеться, щоб IDE просто тримала це перед очима. Ось тут і з’являються Watches — спостереження за змінними або виразами.
Watches — це зазвичай вікно в IDE, куди ви додаєте вирази, і вони обчислюються щоразу, коли виконання зупинено. Це зручно, але важливо пам’ятати про дві речі.
Перше: watch — це не «магічне читання пам’яті», а обчислення виразу. Якщо ви додасте щось на кшталт someObject.expensiveComputedProperty, ви можете ненароком зробити налагодження повільним, а іноді навіть змінити стан, якщо обчислювана властивість має побічні ефекти.
Друге: watches особливо корисні для простих значень — чисел та індексів. Наприклад, у нашому парсері команд має сенс спостерігати tokens.count і first. Це найнедорогіші та найінформативніші величини.
Давайте поліпшимо наш приклад і додамо місце, де watches дуже зручні. Уявімо, що ми додаємо книги в масив:
import Foundation
struct Book {
let title: String
let year: Int
}
var books: [Book] = []
func apply(_ command: Command) {
switch command {
case .list:
print("Книги:", books.count) // Книги: N
case .add(let title, let year):
books.append(Book(title: title, year: year))
}
}
Якщо ви поставите брейкпоінт на books.append(...) і додасте watch books.count, то бачитимете зростання кількості книг без потреби щоразу писати p books.count.
Watchpoint: «зупини мене, коли це зміниться»
Окрім «спостережень» (watches), у LLDB є ідея watchpoint: це механізм, який каже налагоджувачу «зупини виконання, коли значення в пам’яті зміниться». Звучить як суперсила. Іноді це справді суперсила, особливо коли ви не розумієте, хто змінює змінну.
Але є нюанс: watchpoints зав’язані на деталі пам’яті, а Swift активно використовує value-типи та Copy-on-Write для колекцій. Це означає, що для деяких змінних watchpoint може спрацьовувати неочікувано, не спрацьовувати взагалі або спрацьовувати занадто часто. Тому новачкові варто ставитися до watchpoints як до інструменту «коли вже зовсім притисло», а не як до повсякденної звички.
Якщо ви все-таки хочете спробувати, концептуально це виглядає так: ви зупиняєтеся на брейкпоінті, де змінна вже існує, і задаєте watchpoint на неї. У різних середовищах синтаксис LLDB може відрізнятися, але сама ідея лишається: watchpoint працює лише для того, що реально лежить у пам’яті й має стабільну адресу.
Практична рекомендація на вашому рівні звучить так: у 90 % випадків починайте зі звичайних watches в IDE і з p/po, а watchpoints використовуйте лише тоді, коли ви вже локалізували ділянку коду й хочете спіймати того, хто саме сюди пише.
6. Як не потонути у виводі
Є відчуття, що LLDB — це безодня можливостей, і хочеться друкувати все підряд: весь масив книг, усі токени, усі рядки, усі поля всіх структур. Це швидко перетворює налагодження на серіал із 15 сезонами, де ви вже не пам’ятаєте, хто головний герой.
Тут допомагає дуже проста дисципліна: ви друкуєте не «все», а те, що перевіряє вашу гіпотезу. Якщо програма впала на індексації, друкуйте індекс і count. Якщо підозрюєте неправильну гілку умови, друкуйте булеві вирази, які визначають гілку. Якщо у вас парсер, друкуйте токени та їх кількість.
Для нашого прикладу з parseCommand «мінімальний набір» зазвичай такий: po line, po tokens, p tokens.count, po first. Після цього майже завжди стає зрозуміло, у чому проблема: не ті токени, не той формат, порожній рядок, зайві пробіли.
7. Типові помилки
Помилка №1: друкувати змінні, не переконавшись, що ви в правильному кадрі стека.
Це класика: ви зупинилися десь «поруч», набрали p tokens, отримали дивність або «cannot find in scope» і вирішили, що проблема в токенах. Насправді проблема часто в тому, що tokens були локальною змінною всередині іншої функції, а ви зараз у кадрі вище. Спочатку дивіться call stack і вибирайте потрібний frame, а вже потім довіряйте значенням.
Помилка №2: плутати p як «подивитися значення» і «виконати складну магію».
Так, p уміє вирази, але якщо ви почнете викликати методи, які змінюють стан, ви можете «налагодити» програму до нового баґа, якого без налагодження не було. На рівні новачка краще триматися простих виразів: count, перевірок умов, індексів, невеликої арифметики.
Помилка №3: очікувати, що змінна доступна «завжди», навіть до ініціалізації.
Якщо ви стоїте на рядку до присвоєння, змінна може ще не бути в scope, і LLDB чесно відмовиться її показувати. Сучасна налагоджувальна інформація стала точнішою в таких місцях і спеціально уникає показу сміття замість значень. У таких випадках достатньо виконати один крок уперед.
Помилка №4: додавати в Watches важкі вирази й дивуватися, чому налагодження гальмує.
Watch-вирази перераховуються на кожній зупинці. Якщо ви додасте туди щось складне (наприклад, довгу фільтрацію масиву або обчислювану властивість із купою логіки), ви отримаєте відчуття, що «налагоджувач лагає», хоча насправді ви змусили його робити зайву роботу. Watches мають бути короткими й дешевими.
Помилка №5: намагатися замінити налагодженням нормальну діагностику в коді.
LLDB — чудовий інструмент, але він не має ставати костилем «без нього програма незрозуміла». Якщо ви знайшли місце, де постійно доводиться перевіряти tokens.count, значить, коду потрібна нормальна валідація (наприклад, через guard) і нормальна обробка помилок. Налагоджувач допомагає знайти проблему; виправляє її все-таки код.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ