Приспособленец (Flyweight)
Много мелких объектов делят одно неизменяемое общее состояние, а уникальное хранят у себя или получают параметрами.
Проблема
Игра рисует лес на сто тысяч деревьев. Каждое дерево — объект с координатами, а также названием породы, цветом и текстурой. Координаты у всех разные, а пород три: дуб, берёза, ель. Если каждое дерево хранит свою копию текстуры (в игре это мегабайты, а не строка rough), память кончается задолго до того, как лес становится лесом.
Наивное «вынести породу в отдельный объект» не помогает, если каждый вызов new создаёт новый: сто тысяч одинаковых пород всё равно лежат в памяти. Нужно, чтобы одинаковые породы были одним объектом и чтобы этот объект никто не мог поменять из-под соседей.
Решение
Состояние дерева делится на два сорта. Внутреннее (intrinsic) — то, что одинаково у многих: порода, цвет, текстура. Оно уходит в TreeType — неизменяемый объект, который делят все деревья этой породы. Внешнее (extrinsic) — то, что у каждого своё: координаты. Оно остаётся в дереве или передаётся параметром в метод приспособленца: draw(x, y).
Приспособленец обязан быть неизменяемым. Если дуб номер 17 перекрасит свою породу в жёлтый, пожелтеют все дубы: общий объект нельзя править, можно только взять другой.
Структура
Контекст — это Tree и Forest. Дерево хранит только своё: координаты и ссылку на породу; рисуя себя, оно передаёт координаты породе. Лес не создаёт породы сам, а просит их у фабрики, поэтому тысяча посаженных дубов получает одну и ту же ссылку.
Как это собирается
Деревья сажает загрузчик уровня. В примере уровень - это легенда (символ карты -> порода, цвет, текстура) и сама карта, где каждая клетка с символом - одно дерево. Загрузчик идёт по клеткам и зовёт plant() с координатами клетки и данными породы из легенды, а Forest через фабрику TreeTypes отдаёт дереву общий TreeType. Сколько бы дубов ни было на карте, текстура дуба загружается один раз.
Неизвестный символ на карте - ошибка в файле уровня, но не повод не грузить уровень: загрузчик пропускает клетку и пишет её координаты. В конце видно главное: четыре дерева и два типа. Координаты остались в деревьях, всё общее ушло в приспособленцев.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Фабрика TreeTypes — кэш по ключу из внутреннего состояния: если порода с таким ключом уже есть, возвращается она, иначе создаётся и запоминается. Без фабрики приспособленец не работает: именно она гарантирует «одинаковое = тот же объект». Счётчик созданных пород нужен тестам: он показывает экономию числом, без замера памяти.
| Язык | Особенность |
|---|---|
| PHP | readonly class для породы; кэш — массив с ключом-строкой и ??= |
| Go | Сравнимая структура сама служит ключом карты; неизменяемость — через неэкспортируемые поля |
| Python | @dataclass(frozen=True, slots=True): неизменяемость и экономия на __dict__ |
| TypeScript | readonly поля плюс Object.freeze; ключ — строка, потому что Map сравнивает объекты по ссылке |
Запуск: make test T=patterns/structural/flyweight
Плюсы и минусы
Тесты проверяют три вещи. Тысяча деревьев двух пород создаёт ровно две породы: экономия видна по счётчику фабрики. Одинаковое внутреннее состояние даёт тот же объект, а отличие хотя бы в одном поле — другой. Внешнее состояние доходит до приспособленца параметром: два дуба в разных точках рисуются по-разному одной и той же породой.
- ✅ Память растёт с числом уникальных сочетаний, а не с числом объектов.
- ✅ Общие данные загружаются и проверяются один раз (текстура, шрифт, глиф).
- ✅ Неизменяемость приспособленца даёт бесплатную потокобезопасность: делить можно без блокировок.
- ❌ Состояние объекта размазано на два места: часть в нём, часть приходит параметрами. Код читается хуже.
- ❌ Фабрика — кэш, а у кэша свои вопросы: кто его очищает, сколько он живёт, что если уникальных ключей вдруг миллион.
- ❌ Менять приспособленца нельзя, только заменить ссылку на другой: изменение «одного дерева» превращается в поиск или создание новой породы.
Когда не применять
- Объектов немного, и память не проблема: два класса и фабрика вместо одного — лишняя сложность.
- Общей части почти нет: если у каждого объекта свой цвет и своя текстура, кэш будет расти вместе с объектами и только добавит накладных расходов.
- Общее состояние должно меняться у части объектов: приспособленец неизменяем по определению, тут нужна ссылка на изменяемый общий объект, и это уже другой разговор.
Отличия между языками
- PHP —
readonly classделает неизменяемость гарантией языка, а??=укладывает «найти или создать» в одну строку. Ключ собирается строкой: массив PHP не умеет ключ-объект. - Go — структура из строк сравнима, поэтому она сама становится ключом
map[TreeType]*TreeType: отдельный ключ собирать не нужно. Делится указатель, а неизменяемость держится на неэкспортируемых полях: вне пакета их не поменять. - Python —
frozen=Trueзапрещает присваивание и даёт__hash__,slots=Trueубирает__dict__у каждого экземпляра, что для тысяч мелких объектов само по себе заметная экономия. Кэш можно было бы сделатьfunctools.cacheна функции-фабрике, но тогда он глобальный и его не посчитать в тесте. - TypeScript —
readonlyпроверяется только компилятором,Object.freezeзапрещает запись и в рантайме.Mapсравнивает объекты по ссылке, поэтому ключ — строка из полей. Одинаковые строковые литералы движок и так хранит один раз, поэтому приспособленец имеет смысл для объектов, а не для отдельных строк.
Практика
Учебный лес создаётся один раз и больше не меняется. В табличном редакторе стили ячеек меняются постоянно, и вопрос «как изменить общий неизменяемый объект для одной ячейки» становится главным. Практика: стили ячеек таблицы показывает пул стилей на 10 000 ячеек, копирование при записи и то, как это устроено в файле .xlsx.
Связанные темы
- Практика: стили ячеек таблицы — пул неизменяемых стилей и копирование при записи
- Одиночка (Singleton) — один экземпляр на процесс; приспособленец — один экземпляр на ключ
- Прототип (Prototype) — противоположный ход: не делить, а копировать