handbookНаписать

patterns/structural/flyweight/practice/README.md · практика · 4 языка · 19 тестов

база: Приспособленец

Практика: стили ячеек таблицы (Cell styles) ​

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

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

Задача ​

Сервис отчётов выгружает в Excel таблицы на десятки тысяч строк: шапка жирная с заливкой, колонка сумм с денежным форматом, остальное по умолчанию. Первая версия создавала стиль на каждую ячейку: new CellStyle(bold: true) в цикле. Памяти воркера стало не хватать на больших выгрузках, а файл получал по записи в таблице стилей на каждую ячейку, и Excel на самых больших отчётах ругался на слишком много различных форматов.

Вторая версия завела один объект стиля и раздавала его всем ячейкам. Память вернулась, но в предпросмотре отчёта появилась кнопка «выделить жирным». Она меняла поле общего стиля, и жирной становилась вся таблица.

Что урезано ​

  • Файл .xlsx не пишется: таблица стилей и ячейки остаются в памяти. В формате это styles.xml со списком уникальных стилей и атрибут s="3" у каждой ячейки, то есть индекс в этом списке.
  • В стиле четыре поля (шрифт, жирность, заливка, формат числа), а не десятки (границы, выравнивание, защита).
  • Пул стилей только растёт: стили, которыми больше никто не пользуется, не удаляются.
  • Адреса ячеек — строки A1, без разбора на строку и колонку; значения — скаляры без формул и типов.

Решение ​

CellStyle — приспособленец: неизменяемое значение, у которого равенство определяется полями. Чтобы получить другой стиль, его не правят, а строят новый: with(bold: true) в PHP, dataclasses.replace в Python, with({...}) в TypeScript, изменённая копия структуры в Go. Новый стиль пока ничей и не общий: общим его делает пул.

Внутреннее состояние здесь — оформление, внешнее — адрес и значение ячейки. Ячейка хранит значение и ссылку на стиль, в Go — двухбайтовый номер стиля, как в .xlsx.

Структура ​

10 000

one per key

shared

CellStyle

+font: string

+bold: bool

+fill: string

+format: string

+with(changes) : CellStyle

+key() : string

StylePool

-styles: Map<key, CellStyle>

+intern(style) : CellStyle

+count() : int

Cell

+value: scalar

+style: CellStyle

Sheet

-cells: Map<ref, Cell>

+set(ref, value, style)

+restyle(ref, change)

+style(ref) : CellStyle

+styleCount() : int

Пул StylePool отличается от фабрики базового примера: он не создаёт стиль по параметрам, а интернирует готовый. Получил стиль — если равный уже есть, вернул тот, что есть, иначе запомнил этот. Так удобнее для кода, который строит стиль по частям: шапка, потом заливка, потом формат. Ключ — поля стиля: строка в PHP и TypeScript, сам стиль в Python и Go, где неизменяемое значение умеет быть ключом словаря.

Копирование при записи ​

Sheet никогда не меняет стиль, на который смотрят ячейки. restyle(ref, change) берёт текущий стиль ячейки, отдаёт его функции изменения, получает новый стиль и пропускает его через пул. Если такой стиль уже кто-то использует (соседняя ячейка уже жирная), ячейка получит его, и число стилей не вырастет. Если нет, в пуле появится ещё один. Остальные ячейки продолжают смотреть на старый стиль, и он не изменился.

Ячейка без значения перестилизоваться не может: это ошибка с адресом в тексте, а не молчаливое создание пустой ячейки.

Как это собирается ​

Стили создаёт выгрузка отчёта, один раз на файл: обычный, шапка (жирный с заливкой) и денежный формат для колонки сумм. Дальше она идёт по строкам запроса и раскладывает значения по ячейкам Sheet, передавая каждой ячейке один из трёх готовых стилей. Сколько бы строк ни было, в пуле остаются три стиля, потому что Sheet.set() отдаёт их через StylePool.

Потом в предпросмотре жмут "выделить жирным". restyle() строит новый стиль из старого, и пул делает его общим: после B3 стилей четыре, после B2 всё ещё четыре, потому что жирный обычный стиль уже есть. Соседняя B4 осталась обычной. Клик по пустой D9 - ошибка restyle(), которую предпросмотр ловит и просто пропускает.

Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.

Реализации ​

ЯзыкОсобенность
PHPreadonly class и with() с именованными аргументами; пул — массив по строковому ключу с ??=
GoСтиль — сравнимая структура и ключ карты; ячейка хранит StyleID uint16, как s="3" в .xlsx; неизменяемость через копирование значений
Pythonfrozen dataclass хешируем и сам служит ключом; интернирование — setdefault(style, style)
TypeScriptObject.freeze в конструкторе, with() через спред; ключ — строка, потому что Map сравнивает объекты по ссылке

Запуск: make test T=patterns/structural/flyweight/practice

Что проверяют тесты ​

Тесты одинаковы во всех языках и проверяют счётчики и тождество объектов, а не замер памяти: память в тесте зависит от рантайма и шумит, а число созданных стилей — нет.

  • Выгрузка 1000 x 10 = 10 000 ячеек (шапка, колонка сумм, остальное) создаёт ровно три стиля.
  • Ячейка B2 стала жирной, а соседние A2 и C2 — нет, и они по-прежнему делят один стиль. Когда жирной становится и C2, она получает тот же объект, что B2: стилей по-прежнему два.
  • Стиль неизменяем: with возвращает новый объект и не трогает старый, а прямое присваивание поля запрещено языком (readonly class в PHP, FrozenInstanceError в Python, TypeError от замороженного объекта в TypeScript). В Go стиль отдаётся копией: правка копии не доходит до листа.
  • Перестилизовать пустую ячейку нельзя: ошибка называет адрес B7.

Где граница ​

  • Пул только растёт. В долгоживущем редакторе после тысячи правок в нём лежат стили, которых нет ни в одной ячейке. Лечится сборкой мусора при сохранении (пересчитать стили по ячейкам, как это делают библиотеки при записи файла) или слабыми ссылками в пуле.
  • Стили, которые задаются диапазоном (условное форматирование, стиль колонки по умолчанию), — уже не приспособленец, а каскад: итоговый стиль ячейки собирается из слоёв, как в CSS.
  • Пул — общая изменяемая структура. Стили можно читать из многих потоков без блокировок, а пул при параллельной записи нужно защищать.
  • Таблица на двадцать ячеек (форма, карточка) — пул и интернирование избыточны, хватит обычных объектов.

Отличия между языками ​

  • PHP — readonly class запрещает запись в поля на уровне языка, именованные аргументы делают with(bold: true) читаемым без билдера. Ключ пула приходится собирать строкой: массив не принимает объект ключом.
  • Go — самая близкая к файлу .xlsx версия: ячейка хранит номер стиля, лист — таблицу стилей. Неизменяемость получается бесплатно из семантики значений: Style() и функция изменения получают копию. Для интернирования в общем виде в стандартной библиотеке есть пакет unique (Go 1.23).
  • Python — frozen=True даёт и неизменяемость, и __hash__, поэтому стиль сам себе ключ, а setdefault(style, style) — интернирование в одну строку. Тот же приём для строк — sys.intern.
  • TypeScript — readonly проверяет компилятор, Object.freeze — рантайм, и в модулях (строгий режим) запись в замороженный объект бросает TypeError. Map не умеет ключ-значение по полям, поэтому ключ — строка.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS