05 / 06 · Best Practices

📐 Thorsten Ball

Автор "Writing An Interpreter In Go" и "Writing A Compiler In Go", ex-Sourcegraph. Thorsten применяет подход компиляторного инженера к работе с CC: инварианты системы важнее описания реализации. Дай CC ограничения — он выведет правильный код сам.

📐
Thorsten Ball
Автор "Writing An Interpreter/Compiler In Go", ex-Sourcegraph
⚠️
Частая ошибка новичков: описывают реализацию вместо инвариантов — «используй Redis, кеш на 5 минут, ключ в формате user:{id}». CC следует инструкции буквально даже если она неоптимальна. Thorsten Ball's подход: скажите CC что должно быть правдой («чтение профиля пользователя должно занимать <10мс при 1000 одновременных запросов»), а не как это реализовать. CC выберет правильное решение зная constraint — часто лучше чем вы бы выбрали вручную.
📐 Ключевой принцип

"Teach CC invariants, let it derive implementation." Вместо "реализуй функцию X вот так" — "вот инвариант системы, реализуй X не нарушая его". Компилятор думает не в терминах "что написать" — он думает в терминах "что должно быть правдой". CC с инвариантами генерирует код который удовлетворяет constraint, а не просто выглядит похоже.

💡
Тонкость для опытных: инвариант-first подход Thorsten Ball становится особенно мощным в сочетании с типами как документацией. Когда вы описываете инвариант (// INVARIANT: results always sorted by priority DESC, nulls last) и затем определяете тип возвращаемого значения — CC получает двойное ограничение: что должно быть правдой (инвариант) и в каком формате (тип). Эта комбинация даёт CC достаточно контекста чтобы принимать правильные решения даже о деталях реализации которые вы не описывали.
1
"Invariant-first prompting" — ограничения важнее описания

Thorsten работал с компиляторами и интерпретаторами где корректность определяется через инварианты: свойства которые всегда должны быть истинными. Этот подход он перенёс на работу с CC.

Обычный промпт описывает что нужно сделать. Invariant-first промпт описывает что должно быть правдой после того как CC это сделает — и позволяет CC самому выбрать как это обеспечить.

"Не говори CC 'используй двусвязный список'. Скажи 'операция добавления должна быть O(1) и порядок элементов должен сохраняться'. CC выберет подходящую структуру сам — и это будет правильный выбор потому что он знает constraint."

Примеры инвариантов из разных доменов:

Инвариант — AST (Abstract Syntax Tree)
Каждый узел AST имеет ровно одного parent-узла.
Корневой узел — единственный узел без parent.
Добавление узла ForLoop не должно нарушать эти свойства.
Инвариант — финансовые операции
Сумма всех дебетовых транзакций = сумма всех кредитовых транзакций для каждого аккаунта.
Баланс аккаунта никогда не становится отрицательным.
Операция перевода — атомарная: либо обе стороны изменились, либо ни одна.
Инвариант — очередь задач
Задача в состоянии "processing" существует ровно в одном worker-процессе.
Задача не может перейти из "failed" обратно в "processing" без явного retry.
При crash worker — задача возвращается в "pending", не теряется.
// Промпт Thorsten-стиля для добавления узла в AST

/*
Вот структура AST узла:
type Node struct {
    Type     NodeType
    Children []*Node
    Parent   *Node   // nil только для root
    Value    string
}

Инварианты системы (должны соблюдаться ВСЕГДА):
1. node.Parent != nil для всех узлов кроме root
2. Если A.Children содержит B, то B.Parent == A
3. Никаких циклических ссылок в дереве

Задача: реализуй функцию AddChild(parent, child *Node).
Реализация должна обеспечивать выполнение всех трёх инвариантов.
*/

// То что CC сгенерирует — корректное по определению:
func AddChild(parent, child *Node) error {
    if parent == nil {
        return errors.New("parent cannot be nil")
    }
    if child == nil {
        return errors.New("child cannot be nil")
    }
    // Проверка на цикл (инвариант 3)
    if isAncestor(child, parent) {
        return errors.New("adding child would create a cycle")
    }
    // Обновляем parent ссылку (инвариант 1 и 2)
    if child.Parent != nil {
        // Убираем из предыдущего parent
        child.Parent.removeChild(child)
    }
    child.Parent = parent
    parent.Children = append(parent.Children, child)
    return nil
}

// CC вывел этот код потому что знал инвариант 2 и 3 —
// а не потому что мы сказали "обнови Parent поле"
2
"Compiler-as-verifier" паттерн — типы как тесты

Thorsten использует строгую систему типов как "бесплатный верификатор" для кода от CC. Идея: если язык позволяет выразить инвариант через систему типов — компилятор проверит его автоматически, без написания тестов.

Язык/инструмент Команда верификации Что проверяет Уровень
Go go build ./... Типы, импорты, синтаксис Базовый
Go + vet go vet ./... Подозрительные паттерны, races Стандарт
TypeScript strict tsc --noEmit --strict Строгие типы, null safety Стандарт
PHP + PHPStan phpstan analyse --level=8 Статический анализ level 8 (максимум) Строгий
Rust cargo check Borrow checker, lifetimes Очень строгий
PHP синтаксис php -l file.php Синтаксические ошибки Минимум
// Плохо: CC напишет код который может передать неправильный тип
function processPayment(amount: number, currency: string) { ... }
processPayment(100, "XYZ");  // компилятор пропустит, но бизнес-логика сломается

// Хорошо: инвариант "валюта должна быть из известного списка" — в типах
type SupportedCurrency = "RUB" | "USD" | "EUR";

function processPayment(amount: number, currency: SupportedCurrency) { ... }
// processPayment(100, "XYZ");  // TypeScript ERROR: "XYZ" не входит в тип
// CC не сможет передать невалидную валюту — компилятор не даст

// Инвариант "ID не перепутать" через брендированные типы
type UserId = number & { readonly __brand: "UserId" };
type OrderId = number & { readonly __brand: "OrderId" };

function getUser(id: UserId): User { ... }
// getUser(orderId);  // TypeScript ERROR: OrderId !== UserId
// CC случайно не перепутает ID разных сущностей
Типы как документация для CC

Строгие типы не только верифицируют — они документируют инварианты для CC. Когда CC видит SupportedCurrency он понимает ограничения без дополнительных инструкций. Система типов — это машинно-читаемые инварианты. Thorsten называет это "executable documentation".

3
"Algorithmic CC" — правильный контекст для алгоритмов

CC хорошо справляется с алгоритмическими задачами — но только если дать правильный контекст. Без инвариантов CC может сгенерировать O(n²) там где нужен O(log n), или использовать неподходящую структуру данных.

Thorsten применяет этот подход в своих книгах — каждая операция над структурой данных описывается через инварианты и сложность:

# Промпт Thorsten-стиля для алгоритмической задачи

"Вот определение структуры данных:

type BTree struct {
    root *BTreeNode
    order int  // максимальное количество ключей в узле
}

type BTreeNode struct {
    keys     []int
    children []*BTreeNode
    isLeaf   bool
}

Инварианты B-дерева (должны соблюдаться после каждой операции):
1. Каждый узел имеет не более (order) ключей
2. Каждый не-root узел имеет не менее (order/2) ключей
3. Все листовые узлы на одной глубине
4. Для каждого ключа k в узле: все ключи в левом поддереве < k < все ключи в правом поддереве

Реализуй операцию Insert(key int) с временной сложностью O(log n).
При реализации сохраняй все 4 инварианта.
Если вставка нарушает инвариант 1 — делай split узла."

# CC теперь знает:
# - что такое инвариант для этой структуры
# - какая требуемая сложность
# - когда нужен split
# Результат будет корректным по определению инвариантов
// Результат CC на основе инвариантного промпта
func (t *BTree) Insert(key int) {
    if t.root == nil {
        t.root = &BTreeNode{keys: []int{key}, isLeaf: true}
        return
    }

    // Если root переполнен — сначала split (инвариант 1)
    if len(t.root.keys) == t.order {
        newRoot := &BTreeNode{isLeaf: false}
        newRoot.children = append(newRoot.children, t.root)
        t.splitChild(newRoot, 0)
        t.root = newRoot
    }

    t.insertNonFull(t.root, key)
}

func (t *BTree) insertNonFull(node *BTreeNode, key int) {
    i := len(node.keys) - 1

    if node.isLeaf {
        // Вставляем в отсортированной позиции (инвариант 4)
        node.keys = append(node.keys, 0)
        for i >= 0 && key < node.keys[i] {
            node.keys[i+1] = node.keys[i]
            i--
        }
        node.keys[i+1] = key
        return
    }

    // Находим нужное поддерево (инвариант 4)
    for i >= 0 && key < node.keys[i] {
        i--
    }
    i++

    // Если дочерний узел переполнен — split перед спуском (инвариант 1)
    if len(node.children[i].keys) == t.order {
        t.splitChild(node, i)
        if key > node.keys[i] {
            i++
        }
    }
    t.insertNonFull(node.children[i], key)
}
4
"Test the test" подход — тест должен уметь падать

Thorsten применяет принцип из Test-Driven Development (TDD): тест написанный CC нужно верифицировать что он способен упасть. Зелёный тест при сломанной реализации — это не тест, это placeholder.

"Я всегда намеренно ломаю реализацию после того как CC написал тест. Если тест не упал — значит тест ничего не проверяет. Удаляю и прошу написать снова с объяснением что именно проверяется."
// Тест написан CC после реализации Insert
func TestBTreeInsert_MaintainsInvariant1(t *testing.T) {
    tree := NewBTree(3) // order = 3, max 3 ключа в узле

    // Вставить больше ключей чем влезет в один узел
    for i := 1; i <= 10; i++ {
        tree.Insert(i)
    }

    // Проверяем инвариант 1: ни один узел не переполнен
    assertNoNodeExceedsOrder(t, tree.root, tree.order)
}

// Thorsten добавляет: ВРЕМЕННО ломаем реализацию
// func (t *BTree) Insert(key int) {
//     // Намеренно убираем проверку на переполнение root
//     // if len(t.root.keys) == t.order { ... split ... }
//     t.insertNonFull(t.root, key)  // без split → инвариант 1 нарушится
// }
//
// Запускаем: go test -run TestBTreeInsert_MaintainsInvariant1
// ОЖИДАЕМ: FAIL — "node has 4 keys, exceeds order 3"
// ПОЛУЧАЕМ: FAIL ✓ → тест работает
//
// Возвращаем реализацию → PASS ✓

func assertNoNodeExceedsOrder(t *testing.T, node *BTreeNode, order int) {
    t.Helper()
    if node == nil {
        return
    }
    if len(node.keys) > order {
        t.Errorf("node has %d keys, exceeds order %d", len(node.keys), order)
    }
    for _, child := range node.children {
        assertNoNodeExceedsOrder(t, child, order)
    }
}
Mutation testing как автоматизация "Test the Test"

Ручная "сломай и проверь" — трудоёмка для большой кодовой базы. Mutation testing автоматизирует это: инструменты (go-mutesting для Go, infection/infection для PHP) автоматически мутируют код и проверяют что тесты падают. Если мутация не убивается — тест не покрывает этот код. Thorsten использует это особенно для критичной бизнес-логики.

5
"Incremental complexity" — от простого к сложному, каждый шаг отдельно

В своих книгах Thorsten строит интерпретатор и компилятор по слоям: лексер → парсер → AST → evaluator. Каждый слой — полностью работающий компонент прежде чем переходить к следующему. Это же он применяет с CC: никогда не давать всю сложную задачу сразу.

Принцип: каждый шаг должен быть верифицирован тестами прежде чем добавить следующий уровень сложности.

Шаг 1: Простейший случай
CC реализует только базовый сценарий. Никаких edge cases. Никакой обработки ошибок. Работает на happy path.
→ тест: базовый сценарий PASS
Шаг 2: Edge cases входных данных
CC добавляет обработку граничных значений: пустой ввод, максимальный размер, неожиданные типы.
→ тест: edge cases PASS, базовый по-прежнему PASS
Шаг 3: Обработка ошибок
CC добавляет error handling, логирование, recovery. Явные error types.
→ тест: error scenarios PASS, предыдущие по-прежнему PASS
Шаг 4: Производительность
Только если предыдущие шаги работают корректно — добавить оптимизации. Не раньше.
→ бенчмарк: O(log n) подтверждён
# Из книги Thorsten: строим лексер поэтапно

# Шаг 1: базовый токенизатор
"Реализуй Lexer который разбивает строку на токены.
 Пока только: числа (INTEGER) и операторы (+, -, *, /).
 Игнорируй пробелы.
 Напиши тест для: '3 + 4 * 2'."

# [тест проходит] → переходим к шагу 2

# Шаг 2: добавить идентификаторы
"Лексер работает для чисел и операторов.
 Добавь: идентификаторы (letters + digits, starts with letter).
 Ключевые слова: 'let', 'fn', 'return' — отдельные TokenType.
 Добавь тест для: 'let x = 5 + y'."

# [тест проходит] → переходим к шагу 3

# Шаг 3: строки и escaping
"Лексер работает для чисел, операторов, идентификаторов.
 Добавь: строковые литералы в двойных кавычках.
 Обработай: \" (escape кавычка внутри строки), \n, \t.
 Добавь тест для: 'let s = \"hello\\nworld\"'."

# Каждый шаг атомарный — можно откатить если что-то пошло не так
# CC никогда не получает "сделай весь лексер с нуля"
6
Invariant Documentation — инварианты в коде, не в голове

Thorsten документирует инварианты прямо в коде в виде комментариев и assertions. Это двойная польза: люди видят ограничения, CC читает их в контексте и соблюдает при изменениях.

package btree

// BTreeNode представляет узел B-дерева.
//
// ИНВАРИАНТЫ (всегда истинно для корректного дерева):
//   - len(keys) <= order для каждого узла
//   - len(keys) >= order/2 для не-root не-leaf узлов
//   - len(children) == len(keys)+1 для внутренних узлов
//   - len(children) == 0 для leaf узлов
//   - keys отсортированы по возрастанию
type BTreeNode struct {
    keys     []int
    children []*BTreeNode
    isLeaf   bool
}

// verify проверяет все инварианты узла в debug-режиме.
// Вызывается только в тестах и при -race флаге.
func (n *BTreeNode) verify(order int) error {
    if len(n.keys) > order {
        return fmt.Errorf("invariant violated: node has %d keys, max %d",
            len(n.keys), order)
    }
    if !n.isLeaf && len(n.children) != len(n.keys)+1 {
        return fmt.Errorf("invariant violated: internal node has %d children for %d keys",
            len(n.children), len(n.keys))
    }
    for i := 1; i < len(n.keys); i++ {
        if n.keys[i] <= n.keys[i-1] {
            return fmt.Errorf("invariant violated: keys not sorted at position %d", i)
        }
    }
    return nil
}

// Insert добавляет ключ сохраняя все инварианты B-дерева.
//
// POST-CONDITION: после Insert:
//   - key присутствует в дереве (Search(key) == true)
//   - все инварианты BTreeNode сохранены
//   - высота дерева не уменьшилась
func (t *BTree) Insert(key int) {
    // ... реализация ...

    // В тестах: проверяем post-condition
    if isTestMode() {
        if err := t.root.verify(t.order); err != nil {
            panic(fmt.Sprintf("Insert broke invariant: %v", err))
        }
    }
}

Когда CC работает с этим кодом — он читает комментарии с инвариантами и автоматически учитывает их при добавлении новых операций. Это "живая документация" которая влияет на поведение CC.

Книги Thorsten как источник паттернов

"Writing An Interpreter In Go" и "Writing A Compiler In Go" — это не просто книги про компиляторы. Это демонстрация того как строить сложные системы через чёткие инварианты, инкрементальные шаги и тесты которые проверяют реальные свойства. Эти принципы применимы к любому домену — не только компиляторам.

Ресурсы Thorsten Ball

Книги Thorsten доступны на его сайте. Это одни из лучших примеров итеративной разработки сложных систем — именно та методология которую он применяет с CC: