Перейти к содержимому
Фёдор Башинский

Легковес

6 min read
Обложка статьи «Легковес»

Легковес (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 — легковес допускает множество экземпляров, просто не больше одного на уникальный артикул. Одиночка в этом смысле — частный случай с ровно одним ключом.

Когда применять

  • Приложение создаёт очень большое количество похожих объектов, и значительная часть их состояния идентична у многих из них.
  • Внутреннее состояние можно чётко и безопасно отделить от внешнего, и оно действительно не меняется между использованиями.
  • Затраты памяти на хранение повторяющихся данных ощутимо влияют на работу приложения — это паттерн для решения измеримой проблемы, а не для использования «про запас».

Плюсы и минусы

Легковес даёт ощутимую экономию памяти там, где много объектов делят между собой большой объём одинаковых данных, и централизует их создание и кеширование в одном месте — фабрике.

Минус — код усложняется: то, что раньше было обычным свойством объекта, приходится явно разделять на внутреннее и внешнее состояние и передавать второе туда, где оно нужно, при каждом вызове. Разделяемые легковесы обязаны оставаться неизменяемыми, что накладывает ограничение на весь код, который с ними работает. Без реальной, измеренной проблемы с памятью легковес — это чистые накладные расходы на косвенность без какой-либо выгоды взамен.

Итог

Легковес нужен тогда, когда в системе действительно много похожих объектов и это ощутимо бьёт по памяти. Он не ускоряет и не упрощает код сам по себе — он разбивает состояние объекта на общую часть, которую можно безопасно переиспользовать, и уникальную, которая остаётся рядом с местом использования, обменивая более сложное устройство кода на меньший расход памяти.