Flyweight (Легковес)
Flyweight сокращает потребление памяти за счёт разделения общего состояния между множеством объектов.
Паттерн применяется, когда в программе создаётся большое количество похожих объектов, значительная часть состояния которых совпадает. Вместо хранения этого состояния в каждом объекте отдельно, его выносят в разделяемые объекты – легковесы.
Пример
Представим, что мы делаем 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) – уникально для каждого объекта и зависит от контекста. В нашем случае это координаты моба на карте.
Поскольку внутреннее состояние не зависит от контекста, мы можем разделять его между объектами. Выделим его в отдельный класс – легковес:
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 (один для орков, один для эльфов) и разделяем их между всеми мобами. Дополнительную память занимают лишь координаты конкретного моба на карте.
Реальные примеры
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