Flyweight Pattern #
Sebuah game real-time menampilkan hutan dengan sepuluh ribu pohon di layar. Setiap pohon memiliki jenis yang sama — tekstur bark yang identik, mesh daun yang sama, warna yang sama, tinggi rata-rata yang serupa. Yang berbeda hanya posisi masing-masing pohon di peta. Tanpa optimisasi, game itu akan mengalokasikan sepuluh ribu objek Tree yang masing-masing menyimpan salinan data tekstur sebesar 2MB — total 20GB hanya untuk pohon. Dengan Flyweight Pattern, data yang identik disimpan satu kali dan digunakan bersama oleh semua sepuluh ribu pohon; yang dialokasikan per pohon hanya koordinat posisinya. Konsumsi memori turun dari 20GB menjadi 2MB ditambah sepuluh ribu struct kecil berisi koordinat. Itulah kekuatan Flyweight — bukan sekadar optimisasi performa, melainkan yang memungkinkan sistem berskala besar bisa berjalan sama sekali.
Apa itu Flyweight Pattern? #
Flyweight Pattern adalah structural design pattern yang mengurangi konsumsi memori dengan cara berbagi objek yang memiliki state yang sama, alih-alih membuat salinan baru untuk setiap penggunaan. Pattern ini bekerja dengan memisahkan data sebuah objek menjadi dua kategori yang fundamentally berbeda:
- Intrinsic state — data yang tidak berubah dan bisa dibagi ke banyak konteks: tipe karakter, font, tekstur, konfigurasi, template
- Extrinsic state — data yang berbeda untuk setiap konteks dan harus disuplai dari luar: posisi, warna instance, ID, timestamp
Flyweight object hanya menyimpan intrinsic state — yang dijamin immutable dan bisa di-share. Extrinsic state diberikan saat operasi dipanggil, bukan disimpan di dalam objek.
Tiga properti yang mendefinisikan Flyweight:
- Shared dan immutable — flyweight object di-share oleh banyak client; tidak satu pun boleh memodifikasinya
- Factory sebagai gatekeeper — client tidak pernah membuat flyweight secara langsung; selalu melalui factory yang mengelola pool
- Extrinsic state diinjeksikan — data yang berbeda per konteks diberikan saat method dipanggil, bukan disimpan di flyweight
flowchart LR
subgraph "Tanpa Flyweight — 10.000 objek"
direction TB
T1["Tree 1\n- texture: 2MB\n- mesh: 1MB\n- x: 100, y: 200"]
T2["Tree 2\n- texture: 2MB (salinan!)\n- mesh: 1MB (salinan!)\n- x: 350, y: 80"]
T3["Tree N\n- texture: 2MB (salinan!)\n- mesh: 1MB (salinan!)\n- x: ..., y: ..."]
note1["Total: 10.000 × 3MB = ~30GB"]
end
subgraph "Dengan Flyweight — 1 flyweight + 10.000 konteks"
direction TB
FW["TreeFlyweight\n- texture: 2MB (shared)\n- mesh: 1MB (shared)"]
C1["TreeContext 1\n- x: 100, y: 200"]
C2["TreeContext 2\n- x: 350, y: 80"]
C3["TreeContext N\n- x: ..., y: ..."]
C1 & C2 & C3 -->|referensi| FW
note2["Total: 3MB + 10.000 × ~16B = ~3MB"]
end
Intrinsic vs Extrinsic: Cara Memisahkan State #
Pemisahan intrinsic dan extrinsic state adalah keputusan desain terpenting di Flyweight Pattern. Salah memisahkan bisa membuat pattern tidak efektif atau bahkan berbahaya.
Pertanyaan untuk setiap field:
"Apakah nilai ini SAMA untuk semua instance dengan 'tipe' yang sama?"
Jika YA → Intrinsic state → simpan di Flyweight (shared, immutable)
Jika TIDAK → Extrinsic state → berikan saat method dipanggil
Contoh pemisahan untuk beberapa domain:
| Domain | Intrinsic (disimpan di Flyweight) | Extrinsic (disuplai dari luar) |
|---|---|---|
| Text editor | Karakter, font, ukuran, warna default | Posisi X/Y, warna highlight, selected |
| Game particle | Tekstur, warna, ukuran, sprite | Posisi, kecepatan, umur, opacity |
| Map pohon | Tipe pohon, mesh, tekstur, tinggi rata-rata | Posisi X/Z, rotasi, skala instance |
| Icon system | Data gambar, format | Ukuran tampilan, posisi, warna overlay |
| Template email | HTML template, CSS, variabel placeholder | Nama penerima, link, konten dinamis |
// Cara salah: semua data ada di satu struct — tidak ada sharing
type Bullet struct {
// Intrinsic — sama untuk semua bullet dari jenis yang sama
SpritePath string // ← harusnya di Flyweight
Damage int // ← harusnya di Flyweight
Speed float64 // ← harusnya di Flyweight
// Extrinsic — berbeda untuk setiap bullet
X, Y float64
VelX, VelY float64
OwnerID string
}
// 1000 bullet = 1000 salinan SpritePath, Damage, Speed
// Cara benar: pisahkan intrinsic dan extrinsic
type BulletType struct { // Flyweight — shared
SpritePath string
Damage int
Speed float64
}
type BulletInstance struct { // Context — per instance
bulletType *BulletType // referensi ke flyweight
X, Y float64
VelX, VelY float64
OwnerID string
}
// 1000 bullet = 1 BulletType + 1000 BulletInstance (lebih kecil)
Struktur dan Komponen #
classDiagram
class Flyweight {
<<interface>>
+Render(x, y int, extrinsic ExtrinsicState)
}
class ConcreteFlyweight {
-char string
-font string
-color string
+Render(x, y int, extrinsic ExtrinsicState)
+GetIntrinsicState() IntrinsicState
}
class FlyweightFactory {
-pool map[string]Flyweight
-mu sync.RWMutex
+Get(key string) Flyweight
+Count() int
}
class Client {
-factory FlyweightFactory
-contexts []Context
+AddChar(char, font string, x, y int)
+Render()
}
Flyweight <|.. ConcreteFlyweight
FlyweightFactory o-- Flyweight : manages pool
Client --> FlyweightFactory : requests flyweight
Client --> Flyweight : uses with extrinsic state
| Komponen | Peran | Thread-safety |
|---|---|---|
| Flyweight interface | Kontrak untuk semua flyweight | N/A |
| ConcreteFlyweight | Menyimpan intrinsic state; immutable setelah dibuat | Aman — immutable |
| FlyweightFactory | Mengelola pool; membuat atau mengembalikan flyweight | Perlu mutex |
| Client | Menyimpan extrinsic state; menggunakan flyweight via factory | Tergantung implementasi |
Implementasi Lengkap: Text Editor #
Text editor adalah use case klasik Flyweight — sebuah dokumen panjang bisa memiliki jutaan karakter, tetapi hanya ada ratusan kombinasi unik (karakter × font × ukuran).
Flyweight: CharacterGlyph #
package texteditor
import "fmt"
// GlyphStyle mendefinisikan tampilan visual sebuah karakter.
// Ini adalah intrinsic state — sama untuk semua instance karakter yang sama.
type GlyphStyle struct {
Font string
Size int
Bold bool
Italic bool
BaseColor string
}
// CharacterGlyph adalah Flyweight — menyimpan data yang sama untuk semua
// kemunculan karakter yang sama dengan style yang sama.
// PENTING: struct ini immutable setelah dibuat; tidak ada setter.
type CharacterGlyph struct {
char rune
style GlyphStyle
}
// ExtrinsicState menyimpan data yang berbeda untuk setiap kemunculan karakter.
// Ini TIDAK disimpan di CharacterGlyph — disuplai saat Render dipanggil.
type ExtrinsicState struct {
X, Y int
SelectionColor string
IsSelected bool
}
// Render menampilkan karakter di posisi yang diberikan.
// Extrinsic state (posisi, selection) diberikan dari luar, bukan disimpan di glyph.
func (g *CharacterGlyph) Render(state ExtrinsicState) {
color := g.style.BaseColor
if state.IsSelected && state.SelectionColor != "" {
color = state.SelectionColor
}
style := ""
if g.style.Bold {
style += "bold "
}
if g.style.Italic {
style += "italic"
}
fmt.Printf("Render '%c' [%s %s%dpt %s] at (%d,%d)\n",
g.char,
g.style.Font,
style,
g.style.Size,
color,
state.X, state.Y,
)
}
// Char mengembalikan karakter yang direpresentasikan glyph ini.
func (g *CharacterGlyph) Char() rune { return g.char }
// Style mengembalikan style dari glyph.
func (g *CharacterGlyph) Style() GlyphStyle { return g.style }
// MemorySize perkiraan ukuran memori glyph ini dalam bytes (untuk demonstrasi).
func (g *CharacterGlyph) MemorySize() int {
return 4 + // rune (char)
len(g.style.Font) +
4 + // int (size)
1 + // bool (bold)
1 + // bool (italic)
len(g.style.BaseColor)
}
FlyweightFactory: GlyphPool #
package texteditor
import (
"fmt"
"sync"
)
// GlyphPool adalah FlyweightFactory — mengelola pool CharacterGlyph.
// Thread-safe untuk digunakan dari multiple goroutine.
type GlyphPool struct {
mu sync.RWMutex
glyphs map[string]*CharacterGlyph
}
// NewGlyphPool membuat pool kosong yang siap digunakan.
func NewGlyphPool() *GlyphPool {
return &GlyphPool{
glyphs: make(map[string]*CharacterGlyph),
}
}
// Get mengembalikan flyweight untuk kombinasi karakter + style yang diminta.
// Jika sudah ada di pool, mengembalikan yang lama. Jika belum, membuat baru.
func (p *GlyphPool) Get(char rune, style GlyphStyle) *CharacterGlyph {
key := buildKey(char, style)
// Optimistic read — kebanyakan kasus adalah cache hit
p.mu.RLock()
if glyph, ok := p.glyphs[key]; ok {
p.mu.RUnlock()
return glyph
}
p.mu.RUnlock()
// Acquire write lock untuk membuat glyph baru
p.mu.Lock()
defer p.mu.Unlock()
// Double-check setelah acquire write lock — race condition prevention
if glyph, ok := p.glyphs[key]; ok {
return glyph
}
glyph := &CharacterGlyph{char: char, style: style}
p.glyphs[key] = glyph
return glyph
}
// Count mengembalikan jumlah flyweight unik yang tersimpan di pool.
func (p *GlyphPool) Count() int {
p.mu.RLock()
defer p.mu.RUnlock()
return len(p.glyphs)
}
// TotalMemory mengembalikan perkiraan total memori yang digunakan pool (bytes).
func (p *GlyphPool) TotalMemory() int {
p.mu.RLock()
defer p.mu.RUnlock()
total := 0
for _, g := range p.glyphs {
total += g.MemorySize()
}
return total
}
// buildKey membuat key unik untuk kombinasi karakter + style.
func buildKey(char rune, style GlyphStyle) string {
boldStr := "0"
if style.Bold {
boldStr = "1"
}
italicStr := "0"
if style.Italic {
italicStr = "1"
}
return fmt.Sprintf("%d|%s|%d|%s|%s|%s",
char, style.Font, style.Size, boldStr, italicStr, style.BaseColor)
}
Client: Document #
package texteditor
// CharacterEntry menghubungkan flyweight dengan extrinsic state-nya.
// Inilah yang disimpan per karakter — jauh lebih kecil dari menyimpan semua data.
type CharacterEntry struct {
glyph *CharacterGlyph // referensi ke flyweight (shared)
state ExtrinsicState // extrinsic state (per instance)
}
// Document adalah client yang mengelola banyak karakter menggunakan Flyweight.
type Document struct {
pool *GlyphPool
characters []CharacterEntry
cursorX int
cursorY int
lineHeight int
charWidth int
}
// NewDocument membuat dokumen baru dengan pool glyph yang dikonfigurasi.
func NewDocument(pool *GlyphPool, lineHeight, charWidth int) *Document {
return &Document{
pool: pool,
characters: make([]CharacterEntry, 0),
lineHeight: lineHeight,
charWidth: charWidth,
}
}
// AddChar menambahkan karakter ke dokumen dengan style yang diberikan.
// Posisi dihitung otomatis berdasarkan urutan penulisan.
func (d *Document) AddChar(char rune, style GlyphStyle) {
// Ambil flyweight dari pool — buat baru hanya jika belum ada
glyph := d.pool.Get(char, style)
entry := CharacterEntry{
glyph: glyph,
state: ExtrinsicState{
X: d.cursorX,
Y: d.cursorY,
},
}
d.characters = append(d.characters, entry)
// Update posisi cursor
if char == '\n' {
d.cursorX = 0
d.cursorY += d.lineHeight
} else {
d.cursorX += d.charWidth
}
}
// AddText menambahkan string teks dengan style yang sama untuk semua karakter.
func (d *Document) AddText(text string, style GlyphStyle) {
for _, char := range text {
d.AddChar(char, style)
}
}
// SetSelection menandai rentang karakter sebagai terseleksi.
func (d *Document) SetSelection(start, end int, selectionColor string) {
for i := start; i < end && i < len(d.characters); i++ {
d.characters[i].state.IsSelected = true
d.characters[i].state.SelectionColor = selectionColor
}
}
// Render menampilkan seluruh dokumen ke layar.
func (d *Document) Render() {
for _, entry := range d.characters {
entry.glyph.Render(entry.state)
}
}
// CharCount mengembalikan jumlah karakter dalam dokumen.
func (d *Document) CharCount() int { return len(d.characters) }
// MemoryReport menampilkan laporan penggunaan memori.
func (d *Document) MemoryReport() {
uniqueGlyphs := d.pool.Count()
poolMemory := d.pool.TotalMemory()
// Setiap CharacterEntry: pointer (8B) + ExtrinsicState (~32B) ≈ 40B
contextMemory := len(d.characters) * 40
fmt.Printf("\n=== Memory Report ===\n")
fmt.Printf("Total characters: %d\n", len(d.characters))
fmt.Printf("Unique glyphs in pool: %d\n", uniqueGlyphs)
fmt.Printf("Pool memory: %d bytes\n", poolMemory)
fmt.Printf("Context memory: %d bytes\n", contextMemory)
fmt.Printf("Total memory: %d bytes\n", poolMemory+contextMemory)
fmt.Printf("Without Flyweight (est): %d bytes\n", len(d.characters)*(poolMemory/uniqueGlyphs+40))
}
Penggunaan: Demonstrasi Penghematan #
func main() {
pool := texteditor.NewGlyphPool()
doc := texteditor.NewDocument(pool, 20, 10)
// Style yang umum digunakan
normalStyle := texteditor.GlyphStyle{Font: "Arial", Size: 12, BaseColor: "#000000"}
boldStyle := texteditor.GlyphStyle{Font: "Arial", Size: 12, Bold: true, BaseColor: "#000000"}
headerStyle := texteditor.GlyphStyle{Font: "Arial", Size: 18, Bold: true, BaseColor: "#1a1a2e"}
// Tambah heading
doc.AddText("Laporan Keuangan Q1 2024\n", headerStyle)
// Tambah ribuan baris teks dengan style normal dan bold
for i := 0; i < 500; i++ {
doc.AddText("Pendapatan: ", boldStyle)
doc.AddText("Rp 1.500.000.000\n", normalStyle)
}
// Meski ada ribuan karakter, pool hanya menyimpan glyph unik
fmt.Printf("Total karakter: %d\n", doc.CharCount())
fmt.Printf("Unique glyphs di pool: %d\n", pool.Count())
// Tandai teks tertentu sebagai selected
doc.SetSelection(0, 24, "#4a90d9")
// Tampilkan laporan memori
doc.MemoryReport()
}
Output menunjukkan efisiensi:
Total karakter: 8024
Unique glyphs di pool: 31 ← hanya 31 glyph unik dari 8024 karakter!
=== Memory Report ===
Total characters: 8024
Unique glyphs in pool: 31
Pool memory: ~1,200 bytes
Context memory: ~320,960 bytes
Total memory: ~322,160 bytes
Without Flyweight (est): ~2,816,424 bytes ← ~8.7x lebih besar!
Studi Kasus Kedua: Game Particle System #
Particle system adalah use case Flyweight yang paling dramatis dalam hal penghematan memori — sebuah efek explosion bisa menghasilkan 50.000 partikel per detik.
package particle
import (
"fmt"
"sync"
"time"
)
// ParticleType adalah Flyweight — menyimpan data yang sama untuk semua partikel
// dari jenis yang sama (api, asap, percikan, salju).
type ParticleType struct {
Name string
SpritePath string // path ke texture file — bisa beberapa MB
FrameCount int // jumlah frame animasi
BlendMode string // "additive", "alpha", "multiply"
DefaultSize float64
}
// Render menggambar satu partikel dengan state yang diberikan dari luar.
func (pt *ParticleType) Render(x, y, size, opacity, rotation float64) {
fmt.Printf("[%s] pos=(%.0f,%.0f) size=%.1f opacity=%.2f rot=%.0f°\n",
pt.Name, x, y, size, opacity, rotation)
}
// ParticleTypeRegistry adalah FlyweightFactory untuk tipe partikel.
type ParticleTypeRegistry struct {
mu sync.RWMutex
types map[string]*ParticleType
}
func NewParticleTypeRegistry() *ParticleTypeRegistry {
return &ParticleTypeRegistry{
types: make(map[string]*ParticleType),
}
}
// Register mendaftarkan tipe partikel baru ke registry.
func (r *ParticleTypeRegistry) Register(name, spritePath, blendMode string, frameCount int, defaultSize float64) {
r.mu.Lock()
defer r.mu.Unlock()
r.types[name] = &ParticleType{
Name: name,
SpritePath: spritePath,
FrameCount: frameCount,
BlendMode: blendMode,
DefaultSize: defaultSize,
}
}
// Get mengambil tipe partikel dari registry.
func (r *ParticleTypeRegistry) Get(name string) (*ParticleType, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
pt, ok := r.types[name]
return pt, ok
}
// ParticleInstance adalah Context — menyimpan extrinsic state per partikel.
// Struct ini sangat kecil karena semua data berat ada di ParticleType (shared).
type ParticleInstance struct {
particleType *ParticleType // referensi ke flyweight — hanya 8 bytes (pointer)
X, Y float64 // posisi
VelX, VelY float64 // kecepatan
Size float64 // ukuran saat ini (bisa berbeda dari default)
Opacity float64 // transparansi (0-1)
Rotation float64 // rotasi dalam derajat
BornAt time.Time // waktu lahir — untuk menghitung umur
TTL time.Duration // time to live
}
// IsAlive memeriksa apakah partikel masih aktif.
func (p *ParticleInstance) IsAlive() bool {
return time.Since(p.BornAt) < p.TTL
}
// Update memperbarui posisi dan opacity partikel berdasarkan waktu berlalu.
func (p *ParticleInstance) Update(dt float64) {
p.X += p.VelX * dt
p.Y += p.VelY * dt
// Fade out seiring waktu berlalu
age := time.Since(p.BornAt).Seconds()
totalLife := p.TTL.Seconds()
p.Opacity = 1.0 - (age / totalLife)
if p.Opacity < 0 {
p.Opacity = 0
}
}
// Render menampilkan partikel menggunakan flyweight type.
func (p *ParticleInstance) Render() {
p.particleType.Render(p.X, p.Y, p.Size, p.Opacity, p.Rotation)
}
// ParticleSystem mengelola ribuan partikel aktif.
type ParticleSystem struct {
registry *ParticleTypeRegistry
particles []*ParticleInstance
}
func NewParticleSystem(registry *ParticleTypeRegistry) *ParticleSystem {
return &ParticleSystem{
registry: registry,
particles: make([]*ParticleInstance, 0, 10000),
}
}
// Emit mengeluarkan sejumlah partikel dari posisi tertentu.
func (ps *ParticleSystem) Emit(typeName string, x, y float64, count int) error {
pt, ok := ps.registry.Get(typeName)
if !ok {
return fmt.Errorf("particle type %q not registered", typeName)
}
for i := 0; i < count; i++ {
// Hanya ParticleInstance yang dibuat per partikel — ParticleType di-share
p := &ParticleInstance{
particleType: pt, // pointer ke flyweight
X: x + randomOffset(),
Y: y + randomOffset(),
VelX: randomVelocity(),
VelY: randomVelocity() - 2, // sedikit ke atas
Size: pt.DefaultSize * randomScale(),
Opacity: 1.0,
Rotation: randomRotation(),
BornAt: time.Now(),
TTL: randomTTL(),
}
ps.particles = append(ps.particles, p)
}
return nil
}
// Update memperbarui semua partikel dan menghapus yang sudah mati.
func (ps *ParticleSystem) Update(dt float64) {
alive := ps.particles[:0] // reuse slice tanpa alokasi baru
for _, p := range ps.particles {
if p.IsAlive() {
p.Update(dt)
alive = append(alive, p)
}
}
ps.particles = alive
}
// Render menampilkan semua partikel aktif.
func (ps *ParticleSystem) Render() {
for _, p := range ps.particles {
p.Render()
}
}
// Stats mengembalikan statistik particle system.
func (ps *ParticleSystem) Stats() (active, uniqueTypes int) {
return len(ps.particles), ps.registry.Count()
}
func randomOffset() float64 { return float64(time.Now().UnixNano()%20 - 10) }
func randomVelocity() float64 { return float64(time.Now().UnixNano()%10-5) * 0.5 }
func randomScale() float64 { return 0.8 + float64(time.Now().UnixNano()%40)*0.01 }
func randomRotation() float64 { return float64(time.Now().UnixNano() % 360) }
func randomTTL() time.Duration { return time.Duration(500+time.Now().UnixNano()%1500) * time.Millisecond }
func (r *ParticleTypeRegistry) Count() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.types)
}
Penggunaan particle system:
func main() {
registry := particle.NewParticleTypeRegistry()
// Daftarkan tipe partikel — dilakukan sekali di startup
registry.Register("fire", "assets/fire_sprite.png", "additive", 16, 24.0)
registry.Register("smoke", "assets/smoke_sprite.png", "alpha", 8, 32.0)
registry.Register("spark", "assets/spark_sprite.png", "additive", 1, 4.0)
registry.Register("debris", "assets/debris_sprite.png", "alpha", 1, 8.0)
ps := particle.NewParticleSystem(registry)
// Simulasi explosion — emit ribuan partikel
_ = ps.Emit("fire", 400, 300, 200)
_ = ps.Emit("smoke", 400, 300, 100)
_ = ps.Emit("spark", 400, 300, 500)
_ = ps.Emit("debris", 400, 300, 50)
active, uniqueTypes := ps.Stats()
fmt.Printf("Active particles: %d, Unique types: %d\n", active, uniqueTypes)
// "Active particles: 850, Unique types: 4"
// 850 ParticleInstance kecil + 4 ParticleType yang di-share
// Game loop simulation
for frame := 0; frame < 60; frame++ {
ps.Update(1.0 / 60.0) // 60fps
}
}
Thread Safety di Factory #
Factory yang diakses dari multiple goroutine harus thread-safe. Ada dua pendekatan di Golang.
// Pendekatan 1: sync.RWMutex — kontrol lebih granular
type ThreadSafePool struct {
mu sync.RWMutex
pool map[string]*CharacterGlyph
}
func (p *ThreadSafePool) Get(key string, createFn func() *CharacterGlyph) *CharacterGlyph {
// Optimistic read — sebagian besar request adalah cache hit
p.mu.RLock()
if glyph, ok := p.pool[key]; ok {
p.mu.RUnlock()
return glyph
}
p.mu.RUnlock()
// Write lock untuk membuat yang baru — dengan double-check
p.mu.Lock()
defer p.mu.Unlock()
if glyph, ok := p.pool[key]; ok { // re-check setelah lock
return glyph
}
glyph := createFn()
p.pool[key] = glyph
return glyph
}
// Pendekatan 2: sync.Map — lebih sederhana, cocok untuk read-heavy
type SyncMapPool struct {
pool sync.Map
}
func (p *SyncMapPool) Get(key string, createFn func() *CharacterGlyph) *CharacterGlyph {
// LoadOrStore: atomic check-and-store
actual, loaded := p.pool.LoadOrStore(key, createFn())
if loaded {
// Ada yang sudah ada — buang yang baru kita buat
}
return actual.(*CharacterGlyph)
}
// Catatan: sync.Map.LoadOrStore tetap memanggil createFn() sebelum check
// Untuk creation yang mahal, gunakan sync.RWMutex dengan double-check
// Pendekatan 3: singleflight — mencegah multiple goroutine membuat yang sama
import "golang.org/x/sync/singleflight"
type SingleflightPool struct {
group singleflight.Group
mu sync.RWMutex
pool map[string]*CharacterGlyph
}
func (p *SingleflightPool) Get(key string, createFn func() (*CharacterGlyph, error)) (*CharacterGlyph, error) {
p.mu.RLock()
if glyph, ok := p.pool[key]; ok {
p.mu.RUnlock()
return glyph, nil
}
p.mu.RUnlock()
// singleflight memastikan createFn hanya dipanggil SEKALI meski ada banyak
// goroutine yang request key yang sama secara bersamaan
result, err, _ := p.group.Do(key, func() (interface{}, error) {
return createFn()
})
if err != nil {
return nil, err
}
glyph := result.(*CharacterGlyph)
p.mu.Lock()
p.pool[key] = glyph
p.mu.Unlock()
return glyph, nil
}
Testing Flyweight #
func TestGlyphPool_ReturnsSameInstanceForSameKey(t *testing.T) {
pool := texteditor.NewGlyphPool()
style := texteditor.GlyphStyle{Font: "Arial", Size: 12, BaseColor: "#000"}
glyph1 := pool.Get('A', style)
glyph2 := pool.Get('A', style)
// Harus pointer yang sama persis — bukan hanya nilai yang sama
if glyph1 != glyph2 {
t.Error("expected same pointer for same key, got different instances")
}
if pool.Count() != 1 {
t.Errorf("expected 1 glyph in pool, got %d", pool.Count())
}
}
func TestGlyphPool_DifferentStylesCreateDifferentGlyphs(t *testing.T) {
pool := texteditor.NewGlyphPool()
normalStyle := texteditor.GlyphStyle{Font: "Arial", Size: 12}
boldStyle := texteditor.GlyphStyle{Font: "Arial", Size: 12, Bold: true}
glyph1 := pool.Get('A', normalStyle)
glyph2 := pool.Get('A', boldStyle)
if glyph1 == glyph2 {
t.Error("expected different instances for different styles")
}
if pool.Count() != 2 {
t.Errorf("expected 2 glyphs in pool, got %d", pool.Count())
}
}
func TestGlyphPool_ThreadSafety(t *testing.T) {
pool := texteditor.NewGlyphPool()
style := texteditor.GlyphStyle{Font: "Arial", Size: 12}
chars := []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")
var wg sync.WaitGroup
for goroutine := 0; goroutine < 100; goroutine++ {
wg.Add(1)
go func() {
defer wg.Done()
for _, char := range chars {
_ = pool.Get(char, style)
}
}()
}
wg.Wait()
// Setelah 100 goroutine selesai, pool hanya boleh punya jumlah glyph = len(chars)
if pool.Count() != len(chars) {
t.Errorf("expected %d unique glyphs, got %d (possible duplicate creation)", len(chars), pool.Count())
}
}
func TestDocument_FlyweightReducesUniqueObjects(t *testing.T) {
pool := texteditor.NewGlyphPool()
doc := texteditor.NewDocument(pool, 20, 10)
style := texteditor.GlyphStyle{Font: "Arial", Size: 12}
// Tambahkan 1000 karakter 'A'
for i := 0; i < 1000; i++ {
doc.AddChar('A', style)
}
// Meski ada 1000 karakter, pool hanya punya 1 glyph
if pool.Count() != 1 {
t.Errorf("expected 1 flyweight for 1000 identical chars, got %d", pool.Count())
}
if doc.CharCount() != 1000 {
t.Errorf("expected 1000 characters in document, got %d", doc.CharCount())
}
}
Flyweight vs Object Pool #
Dua pattern ini sering dikacaukan karena keduanya melibatkan “berbagi objek”, tapi tujuan dan cara kerjanya berbeda mendasar.
| Aspek | Flyweight | Object Pool |
|---|---|---|
| Tujuan utama | Hemat memori lewat sharing intrinsic state | Hemat biaya creation lewat reuse resource |
| State objek | Immutable — tidak pernah berubah | Mutable — direset setelah dikembalikan ke pool |
| Extrinsic state | Disuplai dari luar saat method dipanggil | Tidak ada — semua state ada di objek |
| Lifecycle | Objek hidup selama pool hidup (sangat panjang) | Objek dipinjam, digunakan, dikembalikan |
| Cocok untuk | Data besar yang bisa di-share (texture, font, template) | Resource mahal yang sering dibuat-dihapus (koneksi DB, worker) |
| Concurrent access | Beberapa client bisa pakai flyweight yang sama sekaligus | Satu client punya exclusive access saat menggunakan |
Kapan Menggunakan dan Kapan Tidak #
GUNAKAN Flyweight jika:
✓ Aplikasi membuat ribuan hingga jutaan objek dari tipe yang sama
✓ Banyak objek memiliki data yang identik (instrinsic state)
✓ Memory usage menjadi bottleneck terukur
✓ Objek bisa dibagi dengan aman antar client (tidak ada shared mutable state)
✓ Data berat (texture, template, config) bisa dipisahkan dari data ringan (posisi, ID)
HINDARI Flyweight jika:
✗ Jumlah objek kecil — overhead kompleksitas tidak terjustifikasi
✗ Tidak ada state yang bisa di-share antar objek
✗ Pemisahan intrinsic/extrinsic tidak natural — memaksa desain yang janggal
✗ Belum ada bukti bahwa memory adalah masalah nyata (premature optimization)
Flyweight dan Mutable State Tidak Bisa Berdampingan
Jika ada satu saja client yang memodifikasi flyweight object, semua client lain yang menggunakan flyweight yang sama akan terdampak tanpa disadari. Ini adalah race condition yang sangat sulit dideteksi. Pastikan field flyweight sepenuhnya immutable — tidak ada setter, tidak ada pointer ke slice atau map yang bisa dimodifikasi.
Checklist Review Flyweight #
DESAIN:
□ Intrinsic dan extrinsic state sudah diidentifikasi dan dipisahkan dengan jelas
□ Flyweight object immutable setelah dibuat — tidak ada setter, field unexported
□ Extrinsic state disuplai saat method dipanggil, tidak disimpan di flyweight
□ Client tidak bisa membuat flyweight secara langsung — hanya melalui factory
FACTORY:
□ Factory thread-safe untuk digunakan dari multiple goroutine
□ Double-check locking atau singleflight untuk mencegah duplicate creation
□ Factory menyediakan method untuk inspeksi pool (Count, TotalMemory)
PENGUKURAN:
□ Ada baseline pengukuran memori sebelum dan sesudah menerapkan Flyweight
□ Penghematan memori terbukti signifikan — bukan premature optimization
TESTING:
□ Test bahwa key yang sama mengembalikan pointer yang identik
□ Test bahwa key berbeda mengembalikan instance berbeda
□ Test thread-safety dengan multiple goroutine
□ Test tidak ada modifikasi pada flyweight setelah dibuat (immutability)
Ringkasan #
- Flyweight mengurangi memori dengan berbagi intrinsic state — data yang identik disimpan satu kali dan direferensikan oleh ribuan konteks, bukan disalin berkali-kali.
- Dua jenis state: intrinsic (immutable, di-share, disimpan di flyweight) dan extrinsic (per-konteks, berbeda tiap instance, disuplai saat method dipanggil).
- Cara memisahkan: tanyakan “apakah nilai ini sama untuk semua instance dari tipe yang sama?” — jika ya, itu intrinsic; jika tidak, itu extrinsic.
- Factory adalah gatekeeper wajib — client tidak boleh membuat flyweight langsung; factory memastikan satu flyweight per key dan mengelola lifecycle pool.
- Thread safety di factory sangat penting — gunakan
sync.RWMutexdengan double-check atausingleflightuntuk mencegah duplicate creation saat multiple goroutine request key yang sama.- Immutability adalah syarat mutlak — satu modifikasi pada flyweight berdampak ke semua client yang menggunakannya; ini bisa menjadi race condition yang sangat sulit dideteksi.
- Flyweight vs Object Pool: Flyweight untuk data besar yang bisa di-share secara bersamaan; Object Pool untuk resource yang dipinjam secara eksklusif kemudian dikembalikan.
- Gunakan hanya ketika ada kebutuhan nyata — ukur konsumsi memori terlebih dahulu; Flyweight menambah kompleksitas desain yang hanya worth it ketika penghematan memorinya signifikan.