第51章:Flyweight ③:演習(大量生成するラベル/アイコン情報)🏷️
ねらい 🎯
- 大量の「同じラベル/同じアイコン」を何度も作ってメモリが重くなる問題を、**共有(Flyweight)**で軽くする感覚を身につけるよ😊
- **共有していい情報(変わらない)**と、**共有しちゃダメな情報(個別に変わる)**を、きれいに分けられるようになるよ🧠✨
- “クラスを増やす”のが目的じゃなくて、オブジェクト数とメモリを減らすのが目的だよ🧹🪄
到達目標 ✅
- 「共有してOKな情報(Intrinsic)」と「個別に持つ情報(Extrinsic)」を説明できる📌
- 同じラベル情報を 1回だけ生成して共有できる(Factory/Cacheで管理)🏭🧊
- テストで「共有できてる!」を **参照同一性(ReferenceEquals)**で確認できる🧪✨
- ざっくりでいいので、割り当て(allocation)が減ったことを数字で見られる📉(
GC.GetAllocatedBytesForCurrentThread()) (Microsoft Learn)
手順 🛠️✨
1) まず「導入前」素朴版を作る 🐣

状況:ECの管理画面で、商品に「NEW」「SALE」みたいな **バッジ(ラベル+アイコン)**をいっぱい表示したい📦🛒 でも素朴にやると、商品1件ごとに同じラベル情報を 何度もnew しちゃう😵💦
-
素朴版の考え方
ProductBadgeがBadge(文字+アイコンデータ)を毎回まるごと持つ- 1万件あったら
Badgeも1万個…🫠
2) 共有対象(Intrinsic)と個別情報(Extrinsic)を分ける ✂️🧠
- 共有してOK(Intrinsic):ラベル文字、アイコンデータ(※同じ種類なら同じ)
- 個別(Extrinsic):どの商品に付くか、表示位置、表示優先度…など
ここがFlyweightのキモ!🪶✨ 「変わらない部品」を共通化して、個別データは軽く持つよ🙂
3) Flyweight(共有オブジェクト)をFactoryで管理する 🏭🧊
- 種類(
LabelKind)ごとに 共有インスタンスを1個だけ作る - 取り出しは
LabelFlyweightFactory.Get(kind)の1行でOKにする✨
種類が固定なら、最初に全部作ってしまうのが一番ラク&安全だよ😊 (遅延生成したいなら
Lazy<T>やConcurrentDictionaryにしてもOK)
4) テストで「共有できてる」を確認する 🧪🔍
- 素朴版:アイコン配列が全部別物になってるはず(最悪パターン)
- Flyweight版:アイコン配列の参照は 種類の数だけのはず(理想)🎉
5) メモリ差は “プロファイラ” でも見てみる(任意)🧰👀
Visual Studioの Memory Usage(メモリ分析)を使うと、どの型が増えてるか見えるよ📊 (Microsoft Learn)
6) AI補助(雛形づくりだけに使う)🤖✍️
例:Copilot/Codexにこう頼むと早いよ👇
-
「MSTestで、素朴版とFlyweight版の生成コードとテストを作って。オブジェクト共有が参照同一性で検証できるようにして」
-
注意:生成されたコードはそのまま採用せず、
- 共有対象が可変になってない?
- Factoryが責務過多になってない?
- テストが “共有を本当に検証” できてる? をチェックするよ👀✨
実装例 🧩(素朴版 → Flyweight版)
※説明しやすいように「アイコン」を byte[](ダミー)で表現するよ。同じ種類のアイコンが毎回newされると激重っていう再現がしやすいの🥺💦
using System;
using System.Collections.Generic;
using System.Linq;
namespace ECommerce.Badges;
// バッジの種類(= 共有キー)
public enum LabelKind
{
New,
Sale,
Limited
}
// --------------------
// 素朴版(導入前)🐣
// --------------------
public sealed record Badge(string Text, byte[] IconBytes);
public sealed record ProductBadge_Naive(int ProductId, int X, int Y, Badge Badge);
// --------------------
// Flyweight版(導入後)🪶
// --------------------
// 共有してOKなもの:Text, IconBytes(不変にして扱うのが大事✨)
public sealed record LabelFlyweight(string Text, ReadOnlyMemory<byte> IconBytes);
// 個別に持つもの:どの商品か、座標など
public readonly record struct BadgeInstance(int ProductId, int X, int Y, LabelKind Kind);
public static class LabelFlyweightFactory
{
// 種類が固定なら「最初に全部作る」が簡単&速い😊
private static readonly IReadOnlyDictionary<LabelKind, LabelFlyweight> Cache =
new Dictionary<LabelKind, LabelFlyweight>
{
{ LabelKind.New, new LabelFlyweight("NEW", CreateFakeIconBytes(size: 4096, seed: 1)) },
{ LabelKind.Sale, new LabelFlyweight("SALE", CreateFakeIconBytes(size: 4096, seed: 2)) },
{ LabelKind.Limited, new LabelFlyweight("LIMITED", CreateFakeIconBytes(size: 4096, seed: 3)) },
};
public static LabelFlyweight Get(LabelKind kind) => Cache[kind];
private static byte[] CreateFakeIconBytes(int size, byte seed)
{
var bytes = new byte[size];
// それっぽく差をつけるだけ(中身は何でもOK)
Array.Fill(bytes, seed);
return bytes;
}
}
テスト例(MSTest)🧪✨
「共有できてる?」を 参照の数で確認するよ🔍 ついでに割り当て量も “ざっくり” 比較してみるよ(差が大きいので安定しやすい)📉 (Microsoft Learn)
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Collections.Generic;
using System.Linq;
using ECommerce.Badges;
namespace ECommerce.Tests;
[TestClass]
public class FlyweightBadgeTests
{
public TestContext? TestContext { get; set; }
[TestMethod]
public void Naive_creates_tons_of_duplicate_icons()
{
var badges = CreateNaive(count: 10_000);
// byte[] は参照型なので Distinct() で「参照の違い」が見れるよ✅
int distinctIconArrays = badges.Select(x => x.Badge.IconBytes).Distinct().Count();
Assert.AreEqual(badges.Count, distinctIconArrays, "素朴版は毎回newしてるので全部別参照になるはず😵");
}
[TestMethod]
public void Flyweight_shares_icons_by_kind()
{
var instances = CreateFlyweightInstances(count: 10_000);
// 各インスタンスが参照する Flyweight のアイコン配列参照を集計
var iconRefs = instances
.Select(i => LabelFlyweightFactory.Get(i.Kind).IconBytes)
.Select(mem => mem.ToArray()) // ※ToArrayは新規配列になるので本当はNG
.ToList();
// ↑ これはわざとダメ例😇:
// ReadOnlyMemory<byte> から ToArray() すると毎回コピーで増える!
// 正しくは「元のbyte[]参照」を共有しているかを見たいので、Factory側を少しだけ工夫するよ👇
}
[TestMethod]
public void Flyweight_shares_underlying_icon_arrays()
{
// Factoryの共有確認をしやすくするため「参照取得」を用意してテストするのがコツ✨
// (下の GetIconArrayRef を使う)
var kinds = new[] { LabelKind.New, LabelKind.Sale, LabelKind.Limited };
// 共有の参照が「種類数」だけに収束することを確認
var list = Enumerable.Range(0, 10_000)
.Select(i => kinds[i % kinds.Length])
.Select(GetIconArrayRef)
.ToList();
int distinct = list.Distinct().Count();
Assert.AreEqual(3, distinct, "Flyweightなら共有は種類数だけになるよ🎉");
}
[TestMethod]
public void Allocation_is_much_smaller_with_flyweight()
{
// ざっくり比較(JIT等の影響を減らすために軽くウォームアップ)
_ = CreateNaive(10);
_ = CreateFlyweightInstances(10);
_ = GetIconArrayRef(LabelKind.New);
long beforeNaive = GC.GetAllocatedBytesForCurrentThread();
_ = CreateNaive(10_000);
long naiveAllocated = GC.GetAllocatedBytesForCurrentThread() - beforeNaive;
long beforeFly = GC.GetAllocatedBytesForCurrentThread();
_ = CreateFlyweightInstances(10_000);
long flyAllocated = GC.GetAllocatedBytesForCurrentThread() - beforeFly;
TestContext?.WriteLine($"Naive allocated: {naiveAllocated:n0} bytes");
TestContext?.WriteLine($"Flyweight allocated: {flyAllocated:n0} bytes");
// アイコン配列(4096 bytes)が毎回newされる vs 種類分だけ なので差は超でかいはず💥
Assert.IsTrue(naiveAllocated - flyAllocated > 20_000_000, "差が小さいなら共有できてないかも😵💫");
}
// --- helpers ---
private static List<ProductBadge_Naive> CreateNaive(int count)
{
var kinds = new[] { LabelKind.New, LabelKind.Sale, LabelKind.Limited };
var list = new List<ProductBadge_Naive>(count);
for (int i = 0; i < count; i++)
{
var kind = kinds[i % kinds.Length];
// 毎回new(=地獄🔥)
var badge = kind switch
{
LabelKind.New => new Badge("NEW", new byte[4096]),
LabelKind.Sale => new Badge("SALE", new byte[4096]),
_ => new Badge("LIMITED", new byte[4096]),
};
list.Add(new ProductBadge_Naive(ProductId: i, X: i % 300, Y: i % 200, Badge: badge));
}
return list;
}
private static List<BadgeInstance> CreateFlyweightInstances(int count)
{
var kinds = new[] { LabelKind.New, LabelKind.Sale, LabelKind.Limited };
var list = new List<BadgeInstance>(count);
for (int i = 0; i < count; i++)
{
var kind = kinds[i % kinds.Length];
list.Add(new BadgeInstance(ProductId: i, X: i % 300, Y: i % 200, Kind: kind));
}
return list;
}
// Factoryの内部で作った「元のbyte[]参照」をそのまま取りたいので、
// ここでは Get(kind).IconBytes が ReadOnlyMemory<byte> を持ってる前提で、
// その "元配列" を参照できるようにする(演習として Factory に小さく足すのがオススメ✨)
private static byte[] GetIconArrayRef(LabelKind kind)
{
var fly = LabelFlyweightFactory.Get(kind);
// ReadOnlyMemory<byte> から元配列を取る(TryGetArray)
if (System.Runtime.InteropServices.MemoryMarshal.TryGetArray(fly.IconBytes, out ArraySegment<byte> seg)
&& seg.Array is not null
&& seg.Offset == 0
&& seg.Count == seg.Array.Length)
{
return seg.Array;
}
Assert.Fail("IconBytes が配列を直接参照してないよ(コピーやスライスになってるかも)😵💫");
return Array.Empty<byte>();
}
}
✅ポイント
ReadOnlyMemory<byte>を “安全な読み取りビュー” として持ちつつ、内部はbyte[]を共有するのが分かりやすいよ🙂ToArray()はコピーだから、共有確認のときは使わないでね(増えちゃう)😇
よくある落とし穴 ⚠️😵💫
- 共有対象が可変(あとから書き換えちゃう) → Flyweightは基本「不変」として扱うのが安全だよ🧊✨
- Factoryが何でも屋になって、依存が集まりすぎる(God Factory化) → “ラベル共有”だけを責務にしてね🏷️
string.Internを雑に使う → 文字列共有の考え方としては便利だけど、インターンされた文字列は長く生きやすいので、入力が無限に増える系には注意だよ⚠️ (Microsoft Learn)- 配列プールを借りたら返すの忘れる(ArrayPool)
→ 借りたら
Return!が基本だよ🧼(配列再利用の仕組み) (Microsoft Learn)
ミニ演習 10〜30分 🧁⏱️
-
上の素朴版を動かして、
Naive_creates_tons_of_duplicate_iconsを通す🧪✅ -
Flyweight版を入れて、
Flyweight_shares_underlying_icon_arraysが通るようにする🪶🎉 -
Allocation_is_much_smaller_with_flyweightを実行して、出力の数値を眺める📉👀 -
追加課題(できたら✨)
LabelKindを1つ増やしても、変更箇所がFactoryだけで済むのを確認する🧠💡- Visual Studioの Memory Usage で、
Badgeやbyte[]が減っているのを見てみる📊 (Microsoft Learn)
自己チェック ✅🌸
- 「共有していい情報」と「個別に持つ情報」を、例を挙げて言える?🗣️
- 共有インスタンスが “種類数だけ” に収まってる?(参照同一性で確認できた?)🔍
ToArray()みたいな コピーで台無しをやってない?😇- “軽くしたい理由”が1文で言える?(例:大量生成でメモリとGCがつらい)🪶💦
おまけ豆知識 🫘✨
- 今のLTSは .NET 10(2025-11-11リリース、2028-11-14までサポート)なので、長く使う教材・サンプルはLTSに寄せると安心だよ😊 (Microsoft Learn)