Практика: стили ячеек таблицы (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.
Структура
Пул StylePool отличается от фабрики базового примера: он не создаёт стиль по параметрам, а интернирует готовый. Получил стиль — если равный уже есть, вернул тот, что есть, иначе запомнил этот. Так удобнее для кода, который строит стиль по частям: шапка, потом заливка, потом формат. Ключ — поля стиля: строка в PHP и TypeScript, сам стиль в Python и Go, где неизменяемое значение умеет быть ключом словаря.
Копирование при записи
Sheet никогда не меняет стиль, на который смотрят ячейки. restyle(ref, change) берёт текущий стиль ячейки, отдаёт его функции изменения, получает новый стиль и пропускает его через пул. Если такой стиль уже кто-то использует (соседняя ячейка уже жирная), ячейка получит его, и число стилей не вырастет. Если нет, в пуле появится ещё один. Остальные ячейки продолжают смотреть на старый стиль, и он не изменился.
Ячейка без значения перестилизоваться не может: это ошибка с адресом в тексте, а не молчаливое создание пустой ячейки.
Как это собирается
Стили создаёт выгрузка отчёта, один раз на файл: обычный, шапка (жирный с заливкой) и денежный формат для колонки сумм. Дальше она идёт по строкам запроса и раскладывает значения по ячейкам Sheet, передавая каждой ячейке один из трёх готовых стилей. Сколько бы строк ни было, в пуле остаются три стиля, потому что Sheet.set() отдаёт их через StylePool.
Потом в предпросмотре жмут "выделить жирным". restyle() строит новый стиль из старого, и пул делает его общим: после B3 стилей четыре, после B2 всё ещё четыре, потому что жирный обычный стиль уже есть. Соседняя B4 осталась обычной. Клик по пустой D9 - ошибка restyle(), которую предпросмотр ловит и просто пропускает.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
| Язык | Особенность |
|---|---|
| PHP | readonly class и with() с именованными аргументами; пул — массив по строковому ключу с ??= |
| Go | Стиль — сравнимая структура и ключ карты; ячейка хранит StyleID uint16, как s="3" в .xlsx; неизменяемость через копирование значений |
| Python | frozen dataclass хешируем и сам служит ключом; интернирование — setdefault(style, style) |
| TypeScript | Object.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не умеет ключ-значение по полям, поэтому ключ — строка.
Связанные темы
- Приспособленец (базовый пример)
- Практика Прототипа: КП из шаблона — неизменяемый товар делится между копиями КП, тот же ход с другой стороны