Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Глава 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! - принудительно перезаписывает значение переменной balance
  • begin - позволяет выполнить несколько действий по очереди (сначала вычесть деньги, потом вернуть остаток)

Проблема подстановки. В Главах 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)) на самом деле состоит из трёх шагов:

  1. Чтение: Взять текущее значение (100).
  2. Вычисление: Вычесть 10 (90).
  3. Запись: Сохранить результат (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.

  • Суть: Вы разбиваете работу языка на два такта, которые бесконечно вызывают друг друга:
    1. EVAL: Анализирует выражение (это вызов функции? это переменная? это число?).
    2. APPLY: Берет функцию и применяет её к аргументам.
  • Зачем это вам: Вы увидите «скелет» любого языка. Вы поймете, что разница между Python, Ruby или JS — это просто небольшие отличия в том, как реализованы eval и apply.

2. Язык как средство проектирования

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

  • Ленивые вычисления (Lazy Evaluation): Вы создадите версию языка, которая не вычисляет аргументы функции до тех пор, пока они реально не понадобятся. Это позволяет работать с бесконечными списками и структурами данных.
  • Недетерминированное программирование (Amb): Вы напишете интерпретатор, который умеет «выбирать» правильный путь. Вы просто описываете условия (например: «найди такое X, чтобы оно было больше 5 и делилось на 2»), и язык сам делает откаты (backtracking), пока не найдет ответ. Это похоже на магию или встроенный в язык Prolog.

3. Логическое программирование

Вы напишете систему, которая работает не на командах «сделай это», а на фактах и правилах (как база данных SQL или язык Prolog). Она умеет выводить новые знания из старых.

Почему это «сносит крышу» практику?

  1. Вы перестаете бояться новых технологий. Когда вы понимаете, как реализовать область видимости (scopes) или передачу аргументов, любой новый язык программирования учится за 2 часа.
  2. Глубокое понимание производительности. Вы увидите, сколько «мусора» и лишних действий генерирует интерпретатор. Вы поймете, почему ваш код на Python медленнее, чем на Rust, на самом базовом уровне.
  3. DSL (Domain Specific Languages). Вы научитесь создавать мини-языки внутри своих проектов. Например, вместо того чтобы писать 1000 if-else для сложной бизнес-логики, вы напишете маленький интерпретатор правил, который будет читать конфиг и исполнять его.