BackendBit

К списку паттернов

Flyweight сокращает потребление памяти за счёт разделения общего состояния между множеством объектов.

Паттерн применяется, когда в программе создаётся большое количество похожих объектов, значительная часть состояния которых совпадает. Вместо хранения этого состояния в каждом объекте отдельно, его выносят в разделяемые объекты – легковесы.

UML диаграмма классов паттерна ЛегковесПоказывает класс MobType (легковес) с полями image, description и race, класс Mob (контекст) с полями type и coordinates, а также MobTypePool, который кэширует и выдаёт экземпляры MobType.MobType+image: String+description: String+race: RaceMobTypePool+getMobType(Race): MobTypeMob+type: MobType+coordinates: Coordinates+move(Coordinates): void+draw(Canvas): voidЛегковесКонтекст

Пример

Представим, что мы делаем 2D-игру, в которой игрок убивает врагов (мобов) и получает за это опыт.

Вот так выглядит класс моба:

package before;

public final class Mob {
    /** Изображение моба */
    private final String image;

    /** Описание моба */
    private final String description;

    /** Раса моба – орк или эльф */
    private final Race race;

    /** Текущие координаты моба на карте */
    private Coordinates coordinates;

    public Mob(String image, String description, Race race,
               Coordinates coordinates) {
        this.image = image;
        this.description = description;
        this.race = race;
        this.coordinates = coordinates;
    }

    public void move(Coordinates newCoordinates) {
        this.coordinates = newCoordinates;
    }

    public void draw(Canvas canvas) {
        // Отрисовка моба на экране
    }
}
java

У каждого моба можно посмотреть дополнительную информацию: изображение, описание и расу. Описание и изображение зависят от расы – у всех орков они одинаковые, у всех эльфов тоже.

Вот карта (локация), на которой размещаются мобы:

package before;

import java.util.ArrayList;
import java.util.List;

public final class GameMap {
    private final List<Mob> mobs = new ArrayList<>();

    public void addMob(Mob mob) {
        mobs.add(mob);
    }

    public void draw(Canvas canvas) {
        for (Mob mob : mobs) {
            mob.draw(canvas);
        }
    }
}
java

В некоторых локациях может быть десятки тысяч мобов. Каждый – отдельный объект с полным набором полей. Это потребляет много памяти.

Можем ли мы сократить потребление? Посмотрим на состав объекта моба. Описание и изображение зависят от расы – значит, у двух мобов одной расы они всегда совпадают. А вот координаты уникальны для каждого моба.

Состояние объекта можно разделить на два вида:

  • Внутреннее (intrinsic) – не зависит от контекста и одинаково для объектов одного вида. В нашем случае это изображение, описание и раса.
  • Внешнее (extrinsic) – уникально для каждого объекта и зависит от контекста. В нашем случае это координаты моба на карте.
Разделение состояния объекта Моб на внутреннее и внешнееСтикмен «Моб» сверху по центру с двумя пунктирными линиями к двум таблицам: слева – внутреннее состояние (Раса, Изображение, Описание), справа – внешнее состояние (Координаты).МобВнутреннее (intrinsic)состояниеРасаИзображениеОписаниеВнешнее (extrinsic)состояниеКоординаты

Поскольку внутреннее состояние не зависит от контекста, мы можем разделять его между объектами. Выделим его в отдельный класс – легковес:

package game;

public record MobType(String image, String description, Race race) {}
java

Теперь класс моба хранит только ссылку на MobType и собственные координаты:

package game;

public final class Mob {
    private final MobType type;
    private Coordinates coordinates;

    public Mob(MobType type, Coordinates coordinates) {
        this.type = type;
        this.coordinates = coordinates;
    }

    public void move(Coordinates newCoordinates) {
        this.coordinates = newCoordinates;
    }

    public void draw(Canvas canvas) {
        // Отрисовка моба на экране
    }
}
java

Объекты MobType можно кэшировать. Создадим для этого пул:

package game;

import java.util.Map;

public final class MobTypePool {
    private final Map<Race, MobType> typesByRace;

    public MobTypePool(Map<Race, MobType> typesByRace) {
        this.typesByRace = Map.copyOf(typesByRace);
    }

    public MobType getMobType(Race race) {
        MobType type = typesByRace.get(race);
        if (type == null) {
            throw new IllegalArgumentException("Unknown race: " + race);
        }
        return type;
    }
}
java

Теперь карта использует пул для создания мобов:

package game;

import java.util.ArrayList;
import java.util.List;

public final class GameMap {
    private final List<Mob> mobs = new ArrayList<>();
    private final MobTypePool mobTypePool;

    public GameMap(MobTypePool mobTypePool) {
        this.mobTypePool = mobTypePool;
    }

    public void addMob(Coordinates coordinates, Race race) {
        MobType type = mobTypePool.getMobType(race);
        mobs.add(new Mob(type, coordinates));
    }

    public void draw(Canvas canvas) {
        for (Mob mob : mobs) {
            mob.draw(canvas);
        }
    }
}
java

Вот как выглядит использование:

var pool = new MobTypePool(Map.of(
    Race.ORC, new MobType("orc.png", "Свирепый орк", Race.ORC),
    Race.ELF, new MobType("elf.png", "Ловкий эльф", Race.ELF)
));

var map = new GameMap(pool);

// Тысячи мобов, но всего два объекта MobType
map.addMob(new Coordinates(10, 20), Race.ORC);
map.addMob(new Coordinates(30, 40), Race.ELF);
map.addMob(new Coordinates(50, 60), Race.ORC);
java

Экономия памяти происходит за счёт того, что мы храним всего два объекта MobType (один для орков, один для эльфов) и разделяем их между всеми мобами. Дополнительную память занимают лишь координаты конкретного моба на карте.

Диаграмма пула легковесов: MobTypePool хранит два объекта MobType, на которые ссылаются все мобыЛокация игры содержит пул MobTypePool с двумя объектами MobType Эльф и MobType Орк. Семь эльфов и шесть орков ссылаются на соответствующий объект MobType.Локация игрыMobTypePoolMobTypeЭльфMobTypeОркЭльфЭльфЭльфЭльфЭльфЭльфЭльфОркОркОркОркОркОрк

Реальные примеры

Integer.valueOf()

Java кэширует часто используемые объекты Integer. При вызове Integer.valueOf() для значений от −128 до 127 возвращается один и тот же объект из кэша, а не создаётся новый:

Integer a = Integer.valueOf(42);
Integer b = Integer.valueOf(42);
System.out.println(a == b); // true – один и тот же объект из кэша

Integer c = Integer.valueOf(200);
Integer d = Integer.valueOf(200);
System.out.println(c == d); // false – разные объекты, 200 вне диапазона кэша
java

Внутри Integer хранится приватный класс IntegerCache, который при загрузке создаёт по умолчанию массив объектов для значений от −128 до 127:

package real;

// Упрощённый исходный код java.lang.Integer
// (конструктор Integer(int) deprecated начиная с Java 9)
@SuppressWarnings({"deprecation", "removal"})
public class IntegerCacheSource {
    private static class IntegerCache {
        static final Integer[] cache;

        static {
            cache = new Integer[256]; // от -128 до 127
            for (int i = 0; i < cache.length; i++) {
                cache[i] = new Integer(i - 128);
            }
        }
    }

    public static Integer valueOf(int i) {
        if (i >= -128 && i <= 127) {
            return IntegerCache.cache[i + 128];
        }
        return new Integer(i);
    }
}
java

Здесь IntegerCache.cache – пул легковесов. Значение числа – внутреннее состояние (оно неизменно и разделяется между всеми, кто запрашивает одно и то же число), а то, где и как этот Integer используется – внешнее.

По тому же принципу работает пул строк (String.intern()) и кэш Boolean.valueOf(), который всегда возвращает один из двух объектов – Boolean.TRUE или Boolean.FALSE.

unique.Make()

Начиная с Go 1.23, пакет unique предоставляет обобщённое интернирование значений – аналог String.intern() из Java. Вызов unique.Make(value) возвращает Handle[T] – ссылку на каноническую копию значения. Если такое же значение уже было интернировано, возвращается тот же Handle:

a := unique.Make("свирепый орк")
b := unique.Make("свирепый орк")
fmt.Println(a == b) // true – один и тот же канонический объект

c := unique.Make("ловкий эльф")
fmt.Println(a == c) // false – разные значения
go

Внутри unique хранит пул канонических значений. Handle содержит указатель на каноническую копию, поэтому сравнение двух Handle – это сравнение указателей (O(1)), а не полных значений:

// Упрощённый код из unique

type Handle[T comparable] struct {
	value *T // указатель на каноническую копию
}

func Make[T comparable](value T) Handle[T] {
	// Ищем значение в пуле (в реальности – LoadOrStore для атомарности)
	if canonical, ok := pool.Load(value); ok {
		return Handle[T]{value: canonical.(*T)}
	}
	// Сохраняем новую каноническую копию
	canonical := new(T)
	*canonical = value
	pool.Store(value, canonical)
	return Handle[T]{value: canonical}
}
go

Здесь пул unique – фабрика легковесов, каноническое значение внутри Handle – внутреннее состояние, а то, где и как этот Handle используется – внешнее.

Тот же принцип используется в пакете net/netip стандартной библиотеки. Тип Addr хранит зону IPv6-адреса через unique.Handle, чтобы тысячи адресов с одной зоной разделяли один объект вместо дублирования строки в каждом:

// Из net/netip
type Addr struct {
	addr uint128                    // конкретный IP-адрес – уникален для каждого Addr
	z    unique.Handle[addrDetail]  // данные о зоне – разделяются между адресами
}

type addrDetail struct {
	isV6   bool
	zoneV6 string
}
go