Глава 3. Модульность, обьекты и состояния
Понятие Состояния (State) и мутации
До этого момента ваши функции были «чистыми»: один и тот же вход всегда давал один и тот же выход ().
Оператор set! (аналог = в обычных языках), позволяет создавать объекты с памятью.
- Суть: Теперь объект может «помнить» свое прошлое. Например, банковский счет, у которого баланс меняется после каждого вызова.
- Зачем вам это: Вы поймете, что как только в коде появляется переменная, которая меняется, ваша программа перестает быть предсказуемой формулой и становится процессом, зависящим от времени.
Раньше, чтобы изменить баланс, вам приходилось передавать его в функцию и получать новый. Теперь объект сам хранит его внутри.
(define (make-withdraw balance)
(lambda (amount)
(if (>= balance amount)
(begin (set! balance (- balance amount)) ; МЕНЯЕМ состояние внутри
balance)
"Недостаточно средств")
)
)
;; Теперь мы можем создать два независимых счета.
;; У каждого будет своя «память»:
(define sasha-acc (make-withdraw 100)) ; Счет Саши со 100 рублями
(define dima-acc (make-withdraw 100)) ; Счет Димы со 100 рублями
(sasha-acc 20) ; Вернет 80. Состояние счета Саши изменилось.
(sasha-acc 20) ; Вернет 60. Вызов тот же, а результат ДРУГОЙ.
(dima-acc 10) ; Вернет 90. Счет Димы никак не зависит от Сашиного.
set!- принудительно перезаписывает значение переменной balancebegin- позволяет выполнить несколько действий по очереди (сначала вычесть деньги, потом вернуть остаток)
Проблема подстановки. В Главах 1 и 2 работал принцип подстановки: если x = 5, вы могли везде в коде заменить x на 5, и ничего бы не сломалось. С появлением состояния этот принцип не работает.
Вы больше не можете просто заменить вызов функции на её значение, потому что результат функции (sasha-acc 20) теперь зависит не только от аргумента 20, но и от того, когда вы её вызвали.
Какой смысл:
- Время имеет значение: В программе появилось понятие «до» и «после». Порядок вызовов теперь критически важен.
- Инкапсуляция: Переменная balance спрятана внутри. Никто не может изменить её напрямую, кроме как через функцию снятия. Это и есть фундамент ООП.
- Цена сложности: С этого момента ваши программы станет в 10 раз сложнее тестировать. Вы не можете просто проверить функцию один раз; вам нужно знать, в каком состоянии она была до теста.
Создание полноценного «объекта» с методами
По сути, объект в ООП — это просто «умная» функция, которая умеет обрабатывать разные команды и хранит внутри себя переменные. В Лиспе это делается через передачу сообщений (Message Passing).
Создаем шаблон объекта (Класс). Мы создаем функцию, которая возвращает другую функцию (объект). Внутри неё спрятаны данные (состояние).
(define (make-account balance)
;; Внутренние функции (Методы)
(define (withdraw amount)
(if (>= balance amount)
(begin (set! balance (- balance amount)) balance)
"Недостаточно денег")
)
(define (deposit amount)
(set! balance (+ balance amount))
balance)
;; Диспетчер (Выбор метода по названию)
(lambda (method)
(cond ((eq? method 'withdraw) withdraw)
((eq? method 'deposit) deposit)
(else (error "Неизвестный метод" method)))
)
)
Применение: Теперь мы создаем «экземпляр» и вызываем его методы, отправляя ему символы-команды.
(define my-acc (make-account 100))
;; Вызываем метод 'withdraw' с аргументом 40
((my-acc 'withdraw) 40) ;; Результат: 60
;; Вызываем метод 'deposit' с аргументом 10
((my-acc 'deposit) 10) ;; Результат: 70
Какой смысл:
- Объект — это замыкание: Переменная
balanceживет внутри «пузыря» функцииmake-account. Снаружи к ней доступа нет. Это инкапсуляция (private fields). - Интерфейс через сообщения: Вы не вызываете код напрямую, вы посылаете объекту «сообщение» (символ
'withdraw). Это чистый полиморфизм: вы можете создать другой объект (например,make-crypto-account), который будет понимать то же сообщение'withdraw, но выполнять его иначе. - Состояние делает код «живым»: Каждый аккаунт — это отдельная сущность со своей историей т.е. данные обладают идентичностью. Как только вы создали такие объекты, вы получили проблему идентичности. В главе 1 и 2 два списка с одинаковыми числами были «одним и тем же». Но теперь с наличием состояния, два счета с балансом 100 — это разные счета. Если вы снимете деньги с одного, второй не изменится.
Aliasing (алиасинг)
Когда у нас два разных имени указывают на одину и ту же память.
(define sasha-acc (make-account 100)) ; Создали ОДИН пузырь
(define dima-acc sasha-acc) ; Просто дали второе имя ТОМУ ЖЕ пузырю
Вы думаете, что у вас «два разных счета», потому что у вас две переменные. Но на самом деле счет один общий.
Проблема в том, что действия через одно имя «тихо» меняют состояние для другого имени, которое об этом даже не подозревает.
;; sasha снимает 90 рублей
((sasha-acc 'withdraw) 90) ;; Остаток: 10
;; dima (в другом конце города) уверен, что у него всё еще 100 рублей.
;; Он пытается снять 50.
((dima-acc 'withdraw) 50) ;; Результат: "Недостаточно денег"
Для dima программа ведет себя недетерминированно. Он не менял свой баланс, но баланс изменился. В функциональном коде (Главы 1-2) такое было в принципе невозможно.
Где вы встретите это в современном коде? В любом языке, где объекты передаются по ссылке (Python, JS, Java, C#):
- JavaScript: Вы передаете объект настроек в функцию. Функция внутри меняет поле
settings.theme = 'dark'. Вы возвращаетесь в основной код и обнаруживаете, что ваш глобальный объект настроек испорчен, хотя вы просто хотели его «прочитать».
Как только появляется состояние, вопрос «Это тот же самый объект или просто похожий?» становится вопросом жизни и смерти всей архитектуры.
Эта проблема масштабируется в многопоточности и ситуация становится еще хуже: dima и sasha могут снять деньги одновременно, и если система не защищена, они оба снимут по 90 рублей с баланса 100, оставив банк в минусе -80.
Авторы SICP писали книгу, когда языки позволяли любому количеству переменных ссылаться на один объект и мутировать его как угодно. Rust же решает проблему алиасинга на уровне компилятора через систему владения (ownership), заимствования (borrowing) и явную мутацию.
Модульность
Инкапсуляция — это не «защита от программиста-дурака». Это способ ограничить область хаоса. Если у вас есть переменная, которую можно менять (set!), вы должны запереть её внутри функции так, чтобы только 2-3 строчки кода могли на неё влиять.
Авторы SICP делают дерзкое заявление: вам не нужны специальные ключевые слова вроде class, private или protected, чтобы иметь полноценное ООП. Всё, что вам нужно — это функции и область видимости.
«Магия» инкапсуляции. В обычном языке (Java/C++) вы доверяете компилятору, что он не пустит вас к приватным полям. В SICP вы видите, почему это работает физически.
Когда вы вызываете функцию, она создает свой «пузырь» (окружение). Если внутри этого пузыря вы создали переменную, а затем вернули наружу другую функцию, эта внутренняя функция «захватывает» переменную с собой. Это называется замыкание (closure).
Объект — это просто функция, которая хранит данные.
Пример: Сейф с паролем
Посмотрите, как создать объект, данные которого физически невозможно достать извне, не зная «секрета».
(define (make-vault secret-code initial-value)
(let ((balance initial-value)) ; Внутреннее состояние
(lambda (provided-code method) ; Возвращаем "интерфейс"
(if (eq? provided-code secret-code)
(cond ((eq? method 'read) balance)
((eq? method 'deposit)
(lambda (amount) (set! balance (+ balance amount)) balance))
(else (error "Нет такого метода")))
(lambda (x) "Неверный пароль!")
)
)
)
)
;; Создаем наш личный сейф
(define my-safe (make-vault 'top-secret 100))
;; Сейф просто не даст вам доступ к методам, если код не совпал.
((my-safe 'wrong-pass 'read) 0) ; Вернет: "Неверный пароль!"
;; Если пароль верный, сейф возвращает нам запрашиваемые данные или функцию-метод.
(my-safe 'top-secret 'read) ; Вернет: 100
;; Пополнение (депозит):
((my-safe 'top-secret 'deposit) 50)
Настоящая приватность: переменная balance не существует для внешнего мира. У вас нет к ней пути, кроме как через вызов функции с верным паролем.
Модульность: Вы можете менять внутреннюю реализацию (например, хранить баланс не в числах, а в списке транзакций), и внешний код даже не заметит подмены, потому что он взаимодействует только с возвращаемой лямбдой.
Какой смысл:
- Где лежит баланс? Если вы наберете в консоли
balance, язык выдаст ошибку: «переменная не найдена». Переменная живет только внутри замыканияmy-safe. Это и есть «приватное поле». - Диспетчеризация: Обратите внимание на
(cond ((eq? method 'read) ...)). Это и есть «таблица методов», которую в Java или C++ за вас строит компилятор. Здесь вы видите её «голые кости». - Безопасность через доступ: Вы не можете «случайно» изменить баланс. Чтобы сделать
set!, вы обязаны пройти черезifс проверкой пароля.
Суть инкапсуляции словами: Мы превращаем «голые данные» в «защищенный процесс».
Параллелизм: время имеет значение
В функциональном программировании (Главы 1–2) времени не существовало: функция всегда возвращала один и тот же результат. В Главе 3 появилось время (порядок выполнения set!), а теперь, в разделе о конкурентности появляется параллельное время.
Гонка данных (Race Condition)
Представьте, что на счету 100. Два человека (два процесса) одновременно снимают по 10.
В коде операция (set! balance (- balance 10)) на самом деле состоит из трёх шагов:
- Чтение: Взять текущее значение (100).
- Вычисление: Вычесть 10 (90).
- Запись: Сохранить результат (90).
Что происходит при конкурентности:
- Процесс А читает баланс: видит 100.
- В этот же микросегмент времени Процесс Б читает баланс: тоже видит 100.
- Процесс А вычитает 10 и записывает 90.
- Процесс Б (который всё еще думает, что там 100) вычитает 10 и записывает 90.
Итог: Сняли 20 рублей, а на счету осталось 90 вместо 80.
Инструментарий предотвращения условий возникновения гонки данных
Чтобы этого не происходило, авторы вводят механизмы контроля доступа:
- Мьютекс (Mutex - Mutual Exclusion): Представьте это как ключ от туалета в кафе. Если вы зашли внутрь и закрылись, никто другой не может зайти, пока вы не выйдете и не отдадите ключ. В коде: процесс «захватывает» мьютекс, меняет баланс и «отпускает» его. Остальные ждут в очереди.
- Сериализаторы: Это высокоуровневая обертка над мьютексами. Вы помечаете процедуру как «сериализованную», и система гарантирует, что одновременно может работать только один вызов этой процедуры.
Дедлок (Deadlock / Взаимная блокировка)
Как только вы вводите Mutex и lock, вы рискуете всё сломать окончательно.
Классический пример из SICP: Обмен деньгами.
- У нас есть два счета: A и B.
- Процесс 1 хочет перевести деньги с A на B. Он блокирует счет A.
- В эту же секунду Процесс 2 хочет перевести с B на A. Он блокирует счет B.
- Теперь Процесс 1 ждет, когда освободится B, чтобы завершить перевод.
- А Процесс 2 ждет, когда освободится A.
Оба стоят вечно. Это и есть Deadlock. Программа зависла, кнопки не нажимаются.
Существует несколько стратегий борьбы с дедлоками, но в SICP и в реальной инженерной практике выделяют три основных способа.
Самый надежный из них — это упорядочивание ресурсов (Hierarchy of Locks).
- Мы присваиваем каждому объекту уникальный ID (например, номер счета). Мы договариваемся, что любой процесс обязан блокировать счета строго в порядке возрастания их ID.
- Как это решает проблему:
- Допустим, у счета A ID = 1, а у счета B ID = 2.
- Процесс 1 (перевод с A на B) видит: . Сначала блокирует 1, потом 2.
- Процесс 2 (перевод с B на A) тоже видит: . Он обязан сначала заблокировать 1, а только потом 2.
- Результат: Процесс 2 не сможет схватить счет B, пока не получит счет A. Он просто встанет в очередь за Процессом 1. Дедлок физически невозможен.
Если мы не можем пронумеровать все объекты в мире, мы используем тактику «вежливого отступления» - попытка захвата с откатом (Try-Lock / Back-off):
- Процесс пытается захватить первый замок. Если получилось, он пытается захватить второй через команду
try-lock(попытаться, но не ждать). - Если второй замок занят, процесс отпускает свой первый замок и ждет случайное время перед новой попыткой.
Почему это работает: Мы разрываем циклическое ожидание. Процесс 1 говорит: «Ой, B занят, ладно, я пока отпущу A, вдруг кому-то он нужен», и тем самым дает Процессу 2 закончить работу.
В Rust дедлоки — это одна из немногих проблем многопоточности, которую компилятор не ловит автоматически. Mutex в Rust защитит вас от порчи данных (Data Race), но он не запретит двум потокам заблокировать друг друга. Поэтому правило «всегда бери замки в одном и том же порядке» для Rust-разработчика является законом.
Важно:
- Атомарность: Операция должна быть либо выполнена целиком, либо не выполнена вовсе. Нельзя давать другим процессам видеть «промежуточное» состояние.
Про что в главе 4
Глава 4 — это, пожалуй, самый мощный интеллектуальный скачок в книге. Если до этого вы были пользователем языка, то теперь вы становитесь его творцом.
Авторы говорят: «Хватит гадать, как работает язык. Давайте его напишем».
1. Метациклический интерпретатор (The Metacircular Evaluator)
Главный фокус главы: вы пишете интерпретатор Lisp на самом Lisp.
- Суть: Вы разбиваете работу языка на два такта, которые бесконечно вызывают друг друга:
- EVAL: Анализирует выражение (это вызов функции? это переменная? это число?).
- APPLY: Берет функцию и применяет её к аргументам.
- Зачем это вам: Вы увидите «скелет» любого языка. Вы поймете, что разница между Python, Ruby или JS — это просто небольшие отличия в том, как реализованы
evalиapply.
2. Язык как средство проектирования
Вместо того чтобы решать задачу на существующем языке, вы учитесь создавать новый язык, в котором задача решается сама собой. В главе разбираются два крутых примера:
- Ленивые вычисления (Lazy Evaluation): Вы создадите версию языка, которая не вычисляет аргументы функции до тех пор, пока они реально не понадобятся. Это позволяет работать с бесконечными списками и структурами данных.
- Недетерминированное программирование (Amb): Вы напишете интерпретатор, который умеет «выбирать» правильный путь. Вы просто описываете условия (например: «найди такое X, чтобы оно было больше 5 и делилось на 2»), и язык сам делает откаты (backtracking), пока не найдет ответ. Это похоже на магию или встроенный в язык Prolog.
3. Логическое программирование
Вы напишете систему, которая работает не на командах «сделай это», а на фактах и правилах (как база данных SQL или язык Prolog). Она умеет выводить новые знания из старых.
Почему это «сносит крышу» практику?
- Вы перестаете бояться новых технологий. Когда вы понимаете, как реализовать область видимости (scopes) или передачу аргументов, любой новый язык программирования учится за 2 часа.
- Глубокое понимание производительности. Вы увидите, сколько «мусора» и лишних действий генерирует интерпретатор. Вы поймете, почему ваш код на Python медленнее, чем на Rust, на самом базовом уровне.
- DSL (Domain Specific Languages). Вы научитесь создавать мини-языки внутри своих проектов. Например, вместо того чтобы писать 1000
if-elseдля сложной бизнес-логики, вы напишете маленький интерпретатор правил, который будет читать конфиг и исполнять его.