Легковес

Легковес (Flyweight) — это структурный паттерн проектирования, который позволяет уместить больше объектов в отведённой памяти за счёт разделения общего состояния между похожими объектами вместо хранения его копии в каждом из них.
Проблема
Сеть автосервисов ведёт учёт всех установленных в автомобили запчастей — счёт идёт на миллионы записей. Каждая запись выглядит так:
class InstalledPart
{
public function __construct(
public readonly string $serialNumber,
public readonly \DateTimeImmutable $installedAt,
public readonly int $mileageAtInstall,
public readonly string $partName,
public readonly string $manufacturer,
public readonly float $weightKg,
public readonly string $material,
public readonly string $technicalDrawingSvg,
) {}
}Создаётся она примерно так:
$part = new InstalledPart(
serialNumber: 'SN-00123456',
installedAt: new \DateTimeImmutable('2026-01-14'),
mileageAtInstall: 45000,
partName: 'Тормозные колодки передние',
manufacturer: 'Bosch',
weightKg: 1.4,
material: 'Керамика',
technicalDrawingSvg: file_get_contents('drawings/brake-pad-bosch-ceramic.svg'),
);Эта конкретная модель тормозных колодок устанавливается тысячи раз в месяц по всей сети, а уникальных артикулов деталей во всём каталоге — пара сотен. Но каждый InstalledPart заново хранит собственную копию названия, производителя, веса, материала и — тяжелее всего — SVG-чертежа на десятки килобайт, хотя все эти поля у любых двух колодок с одним артикулом идентичны байт в байт. Отличаются экземпляры друг от друга только серийным номером, датой установки и пробегом. При миллионах установленных деталей на пару сотен уникальных артикулов это гигабайты задублированных в памяти данных, которые несут ровно одну и ту же информацию снова и снова.
Решение
Легковес разделяет состояние объекта на две части: внутреннее (intrinsic) — то, что не зависит от контекста использования и одинаково для всех объектов одного вида, и внешнее (extrinsic) — то, что уникально для конкретного случая. Внутреннее состояние выносится в отдельные разделяемые объекты, а внешнее остаётся рядом с местом использования.
Внутреннее состояние — название, производитель, вес, материал, чертёж — переезжает в отдельный класс:
final class PartType
{
public function __construct(
public readonly string $partName,
public readonly string $manufacturer,
public readonly float $weightKg,
public readonly string $material,
public readonly string $technicalDrawingSvg,
) {}
}Фабрика гарантирует, что на каждый артикул создаётся только один экземпляр PartType, а повторные запросы того же артикула возвращают уже созданный объект:
class PartTypeFactory
{
/** @var array<string, PartType> */
private array $types = [];
public function get(string $article): PartType
{
return $this->types[$article] ??= $this->load($article);
}
private function load(string $article): PartType
{
// тяжёлая операция — чтение чертежа с диска или из БД,
// выполняется один раз на артикул за всё время работы приложения
return new PartType(
partName: '...',
manufacturer: '...',
weightKg: 0.0,
material: '...',
technicalDrawingSvg: file_get_contents("drawings/{$article}.svg"),
);
}
}InstalledPart теперь хранит только своё уникальное внешнее состояние и ссылку на разделяемый легковес вместо копии его данных:
final class InstalledPart
{
public function __construct(
public readonly string $serialNumber,
public readonly \DateTimeImmutable $installedAt,
public readonly int $mileageAtInstall,
private readonly PartType $type,
) {}
public function describe(): string
{
return sprintf(
'%s (%s), с/н %s, установлена %s при пробеге %d км',
$this->type->partName,
$this->type->manufacturer,
$this->serialNumber,
$this->installedAt->format('d.m.Y'),
$this->mileageAtInstall,
);
}
}Создаём через фабрику:
$factory = new PartTypeFactory();
$part1 = new InstalledPart(
serialNumber: 'SN-00123456',
installedAt: new \DateTimeImmutable('2026-01-14'),
mileageAtInstall: 45000,
type: $factory->get('BOSCH-BP-CER-01'),
);
$part2 = new InstalledPart(
serialNumber: 'SN-00123457',
installedAt: new \DateTimeImmutable('2026-01-15'),
mileageAtInstall: 12000,
type: $factory->get('BOSCH-BP-CER-01'),
);$part1 и $part2 — разные объекты с разными серийными номерами, датами и пробегом, но оба ссылаются на один и тот же объект PartType в памяти: $factory->get('BOSCH-BP-CER-01') во второй раз не создаёт новый объект, а возвращает уже закешированный. Сколько бы колодок этого артикула ни было установлено по всей сети — хоть сто тысяч, — тяжёлая строка с чертежом хранится в памяти ровно один раз.
Из чего состоит паттерн
- Легковес (
PartType) — хранит внутреннее состояние, общее для множества объектов и не зависящее от контекста, в котором они используются. - Фабрика легковесов (
PartTypeFactory) — управляет пулом легковесов и гарантирует, что для одного и того же ключа внутреннего состояния возвращается один и тот же общий экземпляр, а не создаётся новый. - Контекст (
InstalledPart) — хранит внешнее состояние, уникальное для конкретного случая использования, и ссылку на разделяемый легковес. - Клиент — запрашивает легковесы через фабрику вместо прямого создания и передаёт им внешнее состояние там, где оно нужно.
Как отличить внутреннее состояние от внешнего
Граница между внутренним и внешним состоянием — это не техническая деталь, а решение, от которого зависит, будет ли экономия памяти вообще работать. Если бы serialNumber по ошибке оказался внутри PartType, у каждой уникальной детали получился бы собственный «легковес» — фабрика перестала бы что-либо переиспользовать, потому что ключ кеша фактически совпадал бы с уникальным идентификатором объекта.
Есть простой критерий: внутреннее состояние — то, что остаётся верным для объекта независимо от того, где и когда он используется (название и вес колодок Bosch не меняются от того, в какую машину их поставили). Внешнее — то, что имеет смысл только в конкретном случае использования (серийный номер и пробег привязаны к одной конкретной установке одной конкретной детали). Легковес обязан быть неизменяемым — как только в него попадает что-то, способное отличаться между использованиями, он либо перестаёт по-настоящему разделяться, либо начинает возвращать неверные данные части своих контекстов.
Легковес vs Одиночка
Оба паттерна ограничивают бесконтрольное создание объектов через фабричный метод, поэтому их иногда путают, но контролируют они разное:
| Легковес | Одиночка | |
|---|---|---|
| Сколько экземпляров | Много — по одному на каждое уникальное значение внутреннего состояния | Ровно один на всё приложение |
| Ради чего ограничивается создание | Экономия памяти за счёт переиспользования одинаковых данных | Единственная точка доступа к общему ресурсу или состоянию |
| Что служит ключом переиспользования | Значение внутреннего состояния (в примере — артикул) | Ключа нет — единственность не зависит ни от каких параметров |
PartTypeFactory::get('BOSCH-BP-CER-01') и PartTypeFactory::get('NGK-SP-IR-05') вернут два разных объекта PartType — легковес допускает множество экземпляров, просто не больше одного на уникальный артикул. Одиночка в этом смысле — частный случай с ровно одним ключом.
Когда применять
- Приложение создаёт очень большое количество похожих объектов, и значительная часть их состояния идентична у многих из них.
- Внутреннее состояние можно чётко и безопасно отделить от внешнего, и оно действительно не меняется между использованиями.
- Затраты памяти на хранение повторяющихся данных ощутимо влияют на работу приложения — это паттерн для решения измеримой проблемы, а не для использования «про запас».
Плюсы и минусы
Легковес даёт ощутимую экономию памяти там, где много объектов делят между собой большой объём одинаковых данных, и централизует их создание и кеширование в одном месте — фабрике.
Минус — код усложняется: то, что раньше было обычным свойством объекта, приходится явно разделять на внутреннее и внешнее состояние и передавать второе туда, где оно нужно, при каждом вызове. Разделяемые легковесы обязаны оставаться неизменяемыми, что накладывает ограничение на весь код, который с ними работает. Без реальной, измеренной проблемы с памятью легковес — это чистые накладные расходы на косвенность без какой-либо выгоды взамен.
Итог
Легковес нужен тогда, когда в системе действительно много похожих объектов и это ощутимо бьёт по памяти. Он не ускоряет и не упрощает код сам по себе — он разбивает состояние объекта на общую часть, которую можно безопасно переиспользовать, и уникальную, которая остаётся рядом с местом использования, обменивая более сложное устройство кода на меньший расход памяти.