struct กับ class: เจ้าของตัดสิน ไม่ใช่ keyword
คำถามแรกที่ทุกคนถามเมื่อเห็นคำว่า performance คือ เปลี่ยน class เป็น struct แล้วจะเร็วขึ้นไหม
บทที่ 1 เพิ่งประกาศเส้นขอบเขตไปว่าคอร์สนี้ตัดสินทุกข้อสรุปด้วย column Allocated ไม่ใช่ column Mean แล้วบทนี้ก็เป็นบทแรกที่ต้องยอมรับว่าเครื่องมือนั้น ตอบคำถามของบทนี้ไม่ได้ loop 10,000 รอบสี่แบบที่กินต้นทุนต่างกันคนละเรื่อง วัดออกมาได้ 0 byte เท่ากันหมด ไม่ใช่เพราะทั้งสี่แบบเท่ากัน แต่เพราะสิ่งที่ต่างกันไม่ใช่การจองบน heap
บทนี้จึงไม่กลบข้อจำกัดนั้นด้วย graph สวยๆ มันประกาศออกมาตรงๆ แล้วเปลี่ยนไปใช้หลักฐานคนละชนิดแทน คือ ภาษาเครื่องที่ JIT ปล่อยออกมาจริง ซึ่งนับคำสั่ง copy ได้ทีละคำสั่ง และซ้ำได้ทุกครั้งเหมือนกับ column byte
บทนี้ยังเดินบน hot path เส้นเดิมของ domain Order ที่คอร์ส #8–#11 สร้างไว้ คือ parse บรรทัดออร์เดอร์ → รวมยอด → พิมพ์ใบเสร็จ (repo ตัวอย่าง .NET กำลังจัดทำ) ชนิดข้อมูลที่ใช้ตลอดบทคือบรรทัดออร์เดอร์หนึ่งบรรทัด (Id, Name, Qty, PriceSatang) กับ snapshot ของออร์เดอร์หนึ่งใบ ทั้งสองตัวมีทั้ง version struct และ version class เพื่อให้เทียบกันได้ตรงๆ
ทุกตัวเลขในบทนี้วัดบนเครื่อง Intel Core i7-7700 3.60GHz (Kaby Lake) · 8 logical / 4 physical · Ubuntu 24.04.4 LTS (WSL2) · .NET SDK 10.0.302 / .NET 10.0.10 · RyuJIT AVX2 · x64 (IntPtr = 8) และทุก block มี block ID กำกับตามที่บทที่ 1 ตั้ง precedent ไว้
ไวยากรณ์ C# 11–14 ที่ใช้ในบทนี้ (primary constructor บน struct และ class, collection expression = [...]) คอร์สนี้ ใช้ โดยไม่หยุดอธิบาย ถ้าเจอของที่ไม่คุ้นให้เปิด C# 8 → 14: อะไรเปลี่ยนไปบ้าง ควบคู่ไป
”struct อยู่บน stack class อยู่บน heap” เป็นแบบจำลองที่ผิด
หัวข้อที่มีชื่อว่า “”struct อยู่บน stack class อยู่บน heap” เป็นแบบจำลองที่ผิด”ขนาดจริงของชนิดข้อมูลที่ทั้งบทจะใช้ แล้วตามด้วยสามทางที่ struct ตัวเดียวกันเดินไปโผล่บน heap ได้โดยไม่แก้ซอร์สแม้แต่ตัวอักษรเดียว
เริ่มจากของจริงก่อน บรรทัดออร์เดอร์หนึ่งบรรทัดใน2 version ที่มี field เหมือนกันเป๊ะ
using System.Runtime.CompilerServices;
public sealed class OrderLineClass(int id, string name, int qty, int priceSatang){ public int Id = id; public string Name = name; public int Qty = qty; public int PriceSatang = priceSatang;}
public readonly struct OrderLineStruct(int id, string name, int qty, int priceSatang){ public readonly int Id = id; public readonly string Name = name; public readonly int Qty = qty; public readonly int PriceSatang = priceSatang;}
// _sink เป็น static เพื่อกันไม่ให้ escape analysis ของ .NET 10 พิสูจน์ว่า object ไม่หลุด// แล้วย้ายมันไปวางบน stack ซึ่งจะทำให้ตัวเลขที่เราต้องการวัดหายไปเงียบๆ โดยไม่มี warningstatic object? _sink;static long B() => GC.GetAllocatedBytesForCurrentThread();
Console.WriteLine(Unsafe.SizeOf<OrderLineStruct>());for (int i = 0; i < 200; i++) _sink = new OrderLineClass(1042, "ต้มยำกุ้ง", 2, 18000);long a = B();_sink = new OrderLineClass(1042, "ต้มยำกุ้ง", 2, 18000);Console.WriteLine(B() - a);| input ที่วัด | สิ่งที่อ่านได้ |
|---|---|
Unsafe.SizeOf<OrderLineStruct>() | 24 B |
new OrderLineClass(1042, "ต้มยำกุ้ง", 2, 18000) | 40 B |
Unsafe.SizeOf<OrderSnapshot>() | 112 B |
new OrderSnapshotClass() | 128 B |
Unsafe.SizeOf<MutableTally>() | 16 B |
ส่วนต่างระหว่างคู่ struct กับ class ในตารางนี้คือค่าหัวของ object บน heap ซึ่งเป็น layout ของ x64 (IntPtr = 8) ไม่ใช่สัญญาที่ runtime รับประกัน ถ้าย้ายไปสถาปัตยกรรมอื่น ตัวเลขชุดนี้เปลี่ยนทั้งชุด
คำถามที่สำคัญกว่าคือ struct ตัวนั้น อยู่ที่ไหน และคำตอบไม่ได้อยู่ที่ keyword แต่อยู่ที่ว่าใครถือมันไว้
public sealed class HolderOfStruct { public OrderLineStruct Line; }public sealed class HolderOfClass { public OrderLineClass? Line; }
static object MakeHolderOfStruct(){ var h = new HolderOfStruct(); h.Line = new OrderLineStruct(1042, "ต้มยำกุ้ง", 2, 18000); return h; // struct เดินทางไป heap พร้อมเจ้าของ}
static object CaptureStructInClosure(){ var line = new OrderLineStruct(1042, "ต้มยำกุ้ง", 2, 18000); Func<int> f = () => line.Qty * line.PriceSatang; // ถูกจับ -> heap return f;}
static object BoxOneLine(){ var line = new OrderLineStruct(1042, "ต้มยำกุ้ง", 2, 18000); return (object)line;}| input ที่วัด | จำนวน byte | รูปร่างที่ได้ |
|---|---|---|
class { OrderLineStruct Line; } | 40 B | ก้อนเดียว struct ฝังอยู่ในเจ้าของ |
class { OrderLineClass Line; } | 64 B | สองก้อน ตัวถือกับตัวที่ถูกชี้ |
local struct ที่ Func<int> จับไว้ | 104 B | class ปิดล้อมที่ compiler สร้างให้ |
(object)line | 40 B | กล่องที่ห่อ struct ไว้บน heap |
Func<int> ที่ชี้ static method | 0 B | ไม่มีอะไรถูกจับ จึงไม่มีก้อนใหม่ |
new OrderLineStruct[8] | 216 B | array ก้อนเดียว ค่าอยู่ในตัว array |
new OrderLineClass[8] | 88 B | array ของ reference ล้วน ยังไม่มีของ |
new OrderLineClass[8] แล้วเติมของจริงครบ 8 ตัว | 408 B | array บวก object แปดก้อน |
new OrderLineStruct[1000] | 24024 B | ก้อนเดียว |
new OrderLineClass[1000] เติมเต็ม | 48024 B | array บวก object พันก้อน |
แถวที่ควรอ่านช้าๆ คือแถว 88 B กับ 408 B ซึ่งเป็น input เดียวกันแต่คนละสถานะ array ของ class ที่ยังไม่เติมของอ่านได้ 88 B ซึ่ง ดูดี กว่า array ของ struct ที่ 216 B ทันที แต่มันดูดีเพราะยังไม่มีของอยู่ในนั้น พอเติมครบแปดตัวตัวเลขก็ขึ้นไปที่ 408 B
flowchart TD
A["OrderLineStruct หนึ่งตัว ซอร์สไม่เปลี่ยนสักตัวอักษร"] --> B{"ใครเป็นเจ้าของมัน"}
B -- "ตัวแปรโลคัลที่ไม่มีใครจับ" --> C["อยู่ใน frame ของ method ไม่มีก้อนใหม่บน heap"]
B -- "เป็น field ของ class" --> D["เดินทางไป heap พร้อมเจ้าของ อ่านได้ 40 B ก้อนเดียว"]
B -- "ถูก lambda จับไว้" --> E["ไปอยู่ใน class ปิดล้อม อ่านได้ 104 B"]
B -- "ถูกแปลงเป็น object" --> F["ไป heap ในกล่องของตัวเอง อ่านได้ 40 B"]
C --> G["keyword struct ไม่ได้ตัดสินตำแหน่ง เจ้าของตัดสิน"]
D --> G
E --> G
F --> G
คำบรรยายภาพ: เส้นทางของ struct ตัวเดียวกันภายใต้เจ้าของสี่แบบ ตัวเลขทุกตัวมาจากการวัดชุด M2.2 บนเครื่อง i7-7700 เครื่องเดียวกัน · สามในสี่เส้นทางพา struct ขึ้นไปอยู่บน heap โดยที่ซอร์สของตัว struct เองไม่ได้เปลี่ยนแม้แต่ตัวอักษรเดียว · แบบจำลอง stack กับ heap จึงพยากรณ์อะไรไม่ได้เลย ถ้าไม่ดูก่อนว่าใครถือมันไว้
ทางกลับกันก็จริงเหมือนกัน class ที่ไม่หลุดออกนอก method ก็อาจไม่แตะ heap
หัวข้อที่มีชื่อว่า “ทางกลับกันก็จริงเหมือนกัน class ที่ไม่หลุดออกนอก method ก็อาจไม่แตะ heap”method ที่สร้าง object class แล้วใช้มันจบในตัวเอง ซึ่งอ่านได้ 32 byte ที่ tier-0 และ 0 byte ที่ tier-1 จากซอร์สบรรทัดเดียวกัน
sealed class OrderLineNoRefs(int id, int menuId, int qty, int price){ public int Id = id; public int MenuId = menuId; public int Qty = qty; public int Price = price; }
static long LongSink;
[MethodImpl(MethodImplOptions.NoInlining)]static void NonEscaping(){ var line = new OrderLineNoRefs(1042, 7, 2, 18000); LongSink = line.Qty * (long)line.Price; // ไม่หลุดออกจาก method}
[MethodImpl(MethodImplOptions.NoInlining)]static long Probe(){ long a = GC.GetAllocatedBytesForCurrentThread(); NonEscaping(); return GC.GetAllocatedBytesForCurrentThread() - a;}
for (int round = 0; round < 8; round++){ for (int i = 0; i < 2000; i++) NonEscaping(); Console.WriteLine($"round {round}: {Probe()} B"); Thread.Sleep(200);}| input ที่วัด | JIT config | สิ่งที่อ่านได้ |
|---|---|---|
new OrderLineNoRefs(1042, 7, 2, 18000) ที่ไม่หลุด, round 0 | tiering ปกติ (tier-0) | 32 B |
input เดียวกัน, round 1–7 หลัง Thread.Sleep(200) ทุกรอบ | tiering ปกติ (tier-1 แล้ว) | 0 B |
| input เดียวกัน, การเรียกครั้งแรกสุด | DOTNET_TieredCompilation=0 | 0 B |
| input เดียวกัน, round 0 | DOTNET_TC_CallCountingDelayMs=0 | 0 B |
นี่คือฝั่งกลับของหัวข้อที่แล้ว object ที่ประกาศด้วย class ก็ไม่แตะ heap ได้เหมือนกัน ถ้า JIT พิสูจน์ได้ว่ามันไม่หลุดออกนอก method — แต่สังเกตว่าคำตอบมาเป็นตัวเลขสองตัวคู่กัน ไม่ใช่ตัวเลขเดียว process ที่อายุสั้นหรือ cold path จ่ายเลข tier-0 คือ 32 B จริงๆ
และกับดักของบทที่ 1 ก็ยิงซ้ำตรงนี้พอดี ถ้าเปลี่ยนไปอุ่นเครื่องด้วย จำนวนรอบ ใน loop เดียวรวด แทนโครงแปดรอบข้างบนที่ปล่อยให้เวลาเดิน ตัวเลขจะค้างอยู่ที่ 32 B ตลอด วัดที่ 0, 10, 30, 100, 200, 1,000, 5,000, 20,000, 100,000 และ 500,000 ครั้ง ได้ 32 B ทุกจุด แม้จะเติม Thread.Sleep(500) เข้าไปแล้วก็ตาม ขณะที่โครงแปดรอบข้างบนอ่านได้ 0 B ตั้งแต่ round 1 เป็นต้นไป จำนวนรอบจึงไม่ใช่ตัวแปรที่ตัดสิน และสองแถวล่างของตารางก็ปิดคำอธิบายอื่นทิ้ง คือปิด tiering ทั้งระบบด้วย DOTNET_TieredCompilation=0 ได้ 0 B ตั้งแต่การเรียกครั้งแรกสุด และปิดการหน่วงนับการเรียกด้วย DOTNET_TC_CallCountingDelayMs=0 ได้ 0 B ตั้งแต่ round 0 สิ่งที่กั้นอยู่จึงเป็นกลไกเลื่อนขั้นที่บทที่ 1 อธิบายไว้ ไม่ใช่จำนวนครั้งที่คุณเรียกเอง
จุดเดียวในคอร์สที่เครื่องมือหลักของคอร์สตอบไม่ได้
หัวข้อที่มีชื่อว่า “จุดเดียวในคอร์สที่เครื่องมือหลักของคอร์สตอบไม่ได้”loop 10,000 รอบสี่แบบที่ต้นทุนต่างกันคนละเรื่อง แต่ column byte อ่านได้ 0 B เท่ากันทั้งสี่แบบ
code ที่วัดตัดเหลือ loop เดียว อีก3 loop มีรูปร่างเดียวกันเป๊ะ เปลี่ยนแค่ method ที่ถูกเรียกในบรรทัดกลาง
static OrderSnapshot _snap = new() { TotalSatang = 18000, DeliveryFeeSatang = 1500 };static readonly OrderSnapshotClass _snapObj = new() { TotalSatang = 18000, DeliveryFeeSatang = 1500 };static long LongSink;
[MethodImpl(MethodImplOptions.NoInlining)]static long ByValue(OrderSnapshot s) => s.TotalSatang + s.DeliveryFeeSatang;[MethodImpl(MethodImplOptions.NoInlining)]static long ByIn(in OrderSnapshot s) => s.TotalSatang + s.DeliveryFeeSatang;[MethodImpl(MethodImplOptions.NoInlining)]static long ByClass(OrderSnapshotClass s) => s.TotalSatang + s.DeliveryFeeSatang;
static object? LoopByValue(){ long t = 0; for (int i = 0; i < 10_000; i++) t += ByValue(_snap); // copy 112 B ทุกครั้ง LongSink = t; return null;}| input ที่วัด | สิ่งที่เกิดขึ้นจริงใน loop | Allocated |
|---|---|---|
10,000 × ByValue(_snap) | คัดลอก OrderSnapshot 112 byte ทุกครั้ง รวมเป็นข้อมูลที่ถูกย้าย 1.12 MB | 0 B |
10,000 × ByIn(in _snap) | ส่งที่อยู่ ไม่คัดลอก | 0 B |
10,000 × ByClass(_snapObj) | ส่ง reference ของ object ที่มีอยู่แล้ว | 0 B |
10,000 × อ่านผ่าน in บน struct ที่ไม่ใช่ readonly | เดินเส้นทาง defensive copy | 0 B |
ตัวเลข 1.12 MB ในตารางเป็น ปริมาณข้อมูลที่ถูกย้าย ไม่ใช่ byte ที่จองบน heap มันไม่ใช่การอ้างเรื่องประสิทธิภาพ และไม่ได้แปลว่าแถวนั้นช้ากว่าแถวอื่น มันมีไว้ชี้ให้เห็นว่างานที่ต่างกัน 1.12 MB ตกลงไปอยู่ในช่องว่างที่ column Allocated มองไม่เห็น
คอร์สนี้ประกาศไว้ในบทที่ 1 ว่า “byte คือหลักฐาน นาโนวินาทีไม่ใช่” — แล้วบทนี้ก็เป็นบทที่กติกาข้อนั้นไม่พอ
ทั้งสี่แถวข้างบนอ่านได้ 0 B เท่ากันหมด ถ้าเอาข้อมูลชุดนี้ไปวาดเป็น graph เปรียบเทียบสองเลน สิ่งที่ได้คือเลนสองเลนที่แบนเท่ากันและไม่พูดอะไรเลย นั่นไม่ใช่หลักฐานว่า “ทั้งสี่แบบเท่ากัน” มันคือหลักฐานว่า เครื่องมือวัดของคอร์สนี้ไม่ได้ออกแบบมาวัดสิ่งที่บทนี้กำลังพูดถึง
การนับ allocation นับ “การขอพื้นที่ก้อนใหม่บน heap” ส่วนต้นทุนของ struct ที่บทนี้สนใจคือ “การคัดลอกข้อมูลจากที่หนึ่งไปอีกที่หนึ่ง” ซึ่งไม่ขอพื้นที่ใหม่เลย
บทนี้จึงเปลี่ยนไปใช้หลักฐานอีกชนิด คือ ขนาดและหน้าตาของภาษาเครื่องที่ JIT ปล่อยออกมา ซึ่งอ่านซ้ำได้ตรงกันทุกครั้งเหมือน column byte และเห็นคำสั่ง copy ได้ทีละคำสั่ง ส่วน column เวลานั้นบทนี้วัดแล้ว ไม่ผ่านเกณฑ์ของคอร์ส จึงถูกตัดออกทั้งหมด รายละเอียดอยู่ในหัวข้อขีดจำกัดท้ายบท
หลักฐานที่ใช้แทน ต้นทุนการ copy โตตามขนาด และเห็นได้ใน code ที่ JIT ปล่อยออกมา
หัวข้อที่มีชื่อว่า “หลักฐานที่ใช้แทน ต้นทุนการ copy โตตามขนาด และเห็นได้ใน code ที่ JIT ปล่อยออกมา”การไล่ขนาด struct จาก 8 byte ถึง 256 byte แล้วนับคำสั่ง copy ที่ call site ทีละคำสั่ง ซึ่งให้เส้นแบ่งที่คมและซ้ำได้ทุกครั้ง
จุดที่คนดูผิดบ่อยคือดูในตัว method ที่รับ parameter สำเนาไม่ได้เกิดที่นั่น มันเกิดที่ ฝั่งผู้เรียก method ที่ต้องส่องจึงเป็น Call_* ไม่ใช่ Pass_*
code ข้างล่างตัดเหลือสองขนาดที่คร่อมเส้นแบ่งพอดี ของจริงมีครบทุกขนาดในตาราง ตั้งแต่ 8 ถึง 256 byte และมีคู่ที่ส่งด้วย ref ของทุกขนาดด้วย
public struct S16 { public long A, B; [MethodImpl(MethodImplOptions.NoInlining)] public long Sum() => A; }public struct S24 { public long A, B, C; [MethodImpl(MethodImplOptions.NoInlining)] public long Sum() => A; }
static S16 _b; static S24 _c;public static long Sink;
[MethodImpl(MethodImplOptions.NoInlining)]public static long Pass_016_ByValue(S16 s) => s.Sum();[MethodImpl(MethodImplOptions.NoInlining)]public static long Pass_024_ByValue(S24 s) => s.Sum();
// สำเนาเกิดที่ CALL SITE ไม่ใช่ในตัว callee — ต้องดู method พวกนี้[MethodImpl(MethodImplOptions.NoInlining)]public static void Call_016_ByValue() => Sink += Pass_016_ByValue(_b);[MethodImpl(MethodImplOptions.NoInlining)]public static void Call_024_ByValue() => Sink += Pass_024_ByValue(_c);DOTNET_JitDisasm="Call_*" dotnet bin/Release/net10.0/SizeScan.dll| input ที่วัด (call site) | ขนาด code ที่ JIT ปล่อย | จำนวนคำสั่ง copy |
|---|---|---|
| struct 8 byte ส่ง by value | 50 byte | 0 |
| struct 16 byte ส่ง by value | 63 byte | 0 |
| struct 24 byte ส่ง by value | 68 byte | 2 |
| struct 32 byte ส่ง by value | 77 byte | 4 |
| struct 64 byte ส่ง by value | 99 byte | 8 |
| struct 112 byte ส่ง by value | 135 byte | 14 |
| struct 256 byte ส่ง by value | 80 byte | เปลี่ยนไปใช้ rep movsb ที่ ecx=256 |
struct 8 / 112 / 256 byte ส่งด้วย ref | 47 byte ทุกขนาด | 0 ทุกขนาด |
สองแถวแรกคือหัวใจ ที่ 8 และ 16 byte JIT ไม่ออกคำสั่ง copy เลยสักคำสั่ง ค่าเดินทางใน register rdi กับ rsi ตรงๆ คำสั่ง copy โผล่ครั้งแรกที่ 24 byte แล้วโตขึ้นเป็นขั้นบันไดจนถึง 14 คำสั่งที่ 112 byte
ที่ 256 byte ขนาด code กลับ ลดลง เหลือ 80 byte เพราะ JIT เลิกไล่ปล่อยคำสั่ง SIMD ทีละคู่แล้วเปลี่ยนไปใช้ rep movsb แทน — ซึ่งแปลว่าขนาด code ใช้แทนต้นทุนการคัดลอกได้แค่ในช่วงที่ JIT ยังปล่อยคำสั่งเรียงกันเท่านั้น เลยราว 128 byte ไปแล้ว ตัวเลขนี้ไม่ใช่ตัวแทนของอะไรทั้งนั้น ตัวเลขใน column นี้เป็นหลักฐานว่าคำสั่ง copy มีหรือไม่มี ไม่ใช่ค่าประมาณของเวลา
แถวสุดท้ายคือเหตุผลที่ in และ ref มีอยู่ ส่งด้วย reference แล้วขนาด code คงที่ที่ 47 byte ทุกขนาด และไม่มีคำสั่ง copy เลย
คำแนะนำ “instance size under 16 bytes” ของ Framework Design Guidelines คือหน้าที่มี ms.date เป็น 2008-10-22 และตัวหน้าเว็บเองแปะคำเตือนไว้ว่าเนื้อหาอาจไม่สะท้อนแนวปฏิบัติปัจจุบัน
สิ่งที่บทนี้ทำได้คือวัดใหม่เองบน .NET 10.0.10 / x64 แล้วรายงานว่าเส้นแบ่งที่ วัดได้จาก codegen ตรงกับตัวเลขนั้นพอดี คือ 16 byte ยังไม่มีคำสั่ง copy และ 24 byte เริ่มมี
นี่ไม่ใช่การยืนยันว่าเอกสารปี 2008 ถูก มันคือการบอกว่าบนเครื่องนี้ runtime รุ่นนี้ และสถาปัตยกรรมนี้ ตัวเลขสองชุดบังเอิญตรงกัน ถ้าเปลี่ยนสถาปัตยกรรมหรือเปลี่ยนรุ่น runtime ต้องวัดใหม่
List<struct> ไม่ได้แปลว่าประหยัดเสมอ — capacity ต่างหากที่ตัดสิน
หัวข้อที่มีชื่อว่า “List<struct> ไม่ได้แปลว่าประหยัดเสมอ — capacity ต่างหากที่ตัดสิน”เคสเดียวของบทนี้ที่การนับ byte ตอบได้ตรงๆ และคำตอบไม่ใช่คำตอบที่คนส่วนใหญ่คาดไว้
หัวข้อที่แล้วบอกว่าบทนี้ต้องยืมหลักฐานจาก codegen แต่มีคำถามหนึ่งที่ column Allocated ตอบได้เต็มปาก คือคำถามที่คนเปลี่ยน List<OrderLineClass> เป็น List<OrderLineStruct> ใน codebase จริงถามกัน
static readonly string[] Names = ["ต้มยำกุ้ง", "ผัดไทย", "ข้าวมันไก่", "ส้มตำ", "แกงเขียวหวาน", "ข้าวผัดปู", "หมูกระทะ", "ลาบหมู"];
static object BuildListClass(int n, bool withCapacity){ var list = withCapacity ? new List<OrderLineClass>(n) : new List<OrderLineClass>(); for (int i = 0; i < n; i++) list.Add(new OrderLineClass(1042 + i, Names[i & 7], 2, 18000)); return list;}
static object BuildListStruct(int n, bool withCapacity){ var list = withCapacity ? new List<OrderLineStruct>(n) : new List<OrderLineStruct>(); for (int i = 0; i < n; i++) list.Add(new OrderLineStruct(1042 + i, Names[i & 7], 2, 18000)); return list;}| input ที่วัด | List<OrderLineClass> | List<OrderLineStruct> | อัตราส่วน |
|---|---|---|---|
| n=8 ตั้ง capacity ไว้ | 440 B | 248 B | 0.56x |
| n=100 ตั้ง capacity ไว้ | 4856 B | 2456 B | 0.51x |
| n=1000 ตั้ง capacity ไว้ | 48056 B | 24056 B | 0.50x |
| n=2000 ตั้ง capacity ไว้ | 96056 B | 48056 B | 0.50x |
| n=8 ปล่อยให้โตเอง | 496 B | 368 B | 0.74x |
| n=100 ปล่อยให้โตเอง | 6192 B | 6224 B | 1.01x — struct แพ้ |
| n=1000 ปล่อยให้โตเอง | 56600 B | 49304 B | 0.87x |
| n=1025 ปล่อยให้โตเอง | 74008 B | 98480 B | 1.33x — struct แพ้หนักสุด |
ครึ่งบนของตารางคือเรื่องที่ทุกคนคาดไว้ ตั้ง capacity แล้ว List<struct> กิน 0.50x ถึง 0.56x ของ List<class> อย่างสม่ำเสมอ ครึ่งล่างคือเรื่องที่ไม่มีใครคาด
คำถามต่อมาคือ n=100 กับ n=1025 เป็นข้อยกเว้นหรือเป็นเรื่องปกติ วัดหมดทุกค่าเลยดีกว่า
int worse = 0, better = 0; long worstDiff = 0; int worstN = 0;for (int n = 1; n <= 2000; n++){ long c = MeasureOnce(() => BuildListClass(n, false)); long s = MeasureOnce(() => BuildListStruct(n, false)); if (s >= c) { worse++; if (s - c > worstDiff) { worstDiff = s - c; worstN = n; } } else better++;}Console.WriteLine($"{worse}/2000 worse, {better}/2000 better, worst +{worstDiff} B at n={worstN}");1209/2000 worse, 791/2000 better, worst +24472 B at n=1025List<OrderLineStruct> ที่ไม่ตั้ง capacity กินเท่ากันหรือมากกว่า List<OrderLineClass> ใน 1209 จาก 2000 ขนาด และ n แรกที่มันแพ้คือ n=1 ส่วนช่องว่างที่แย่ที่สุดคือ +24472 B ที่ n=1025 ซึ่งเป็นคู่ 98480 B เทียบ 74008 B ในตารางข้างบนพอดี
กลไกอ่านออกจากซอร์สของ List<T> ได้ตรงๆ ค่าเริ่มต้นคือ DefaultCapacity = 4 และตอนขยายใช้ newCapacity = 2 * _items.Length ทุกครั้งที่ขยาย มันจอง array ใหม่ทั้งก้อนแล้วคัดลอกของเก่าลงไป สำหรับ array ของ struct ก้อนใหม่นั้นมี ตัว struct ทุกตัวอยู่ข้างใน ส่วน array ของ class ก้อนใหม่มีแค่ reference ที่ 8 byte ต่อช่อง ที่ n=1025 array จึงถูกขยายไปถึงช่วงที่ struct ต้องจ่ายเต็มราคาซ้ำอีกครั้ง
สิ่งที่ควรจำไม่ใช่เลข 1209 เลขนั้นผูกกับรูปร่างเฉพาะคู่นี้ คือ struct 24 byte เทียบ class 40 byte สิ่งที่ควรจำคือกลไก คือความจุสะสมของการขยายทีละสองเท่า เทียบกับ n ที่ต้องการจริงๆ
defensive copy เป็น bug ความถูกต้องก่อน แล้วค่อยเป็น bug ความเร็ว
หัวข้อที่มีชื่อว่า “defensive copy เป็น bug ความถูกต้องก่อน แล้วค่อยเป็น bug ความเร็ว”เส้นทางที่ code สั่งบวกแล้วผลลัพธ์ออกมาเป็นศูนย์ โดยไม่มี error ไม่มี warning และไม่มีอะไรผิดปกติให้เห็นเลย
ก่อนจะพูดเรื่องราคาของสำเนา ต้องพูดเรื่องที่แพงกว่าก่อน คือกรณีที่สำเนาทำให้ ผลลัพธ์ผิด
defensive copydefensive copyสำเนาทั้งก้อนที่ compiler แอบสร้างเมื่อเรียกสมาชิกที่ไม่ใช่ `readonly` ผ่าน `in` หรือ readonly field ทำให้การแก้ค่าตกลงไปในสำเนาแล้วหายไป คือสำเนาทั้งก้อนที่ compiler แอบสร้างขึ้นมาเมื่อคุณเรียกสมาชิกที่ไม่ใช่ readonly ผ่านทางที่มันถือว่าห้ามแก้ เช่น in parameter หรือ readonly field method นั้นทำงานจริง แต่ทำงานกับสำเนา แล้วสำเนาก็ถูกทิ้งไปเงียบๆ ตอนจบ statement
code ข้างล่างตัดเหลือห้าเส้นทาง ส่วนตารางใต้ block รวมทั้งสิบเอ็ดเส้นทางที่วัด
public struct MutableTally // ไม่ใช่ readonly struct{ public int Count; public long TotalSatang; public void Add(long satang) { Count++; TotalSatang += satang; } public readonly override string ToString() => $"Count={Count}, TotalSatang={TotalSatang}";}
public sealed class Basket{ public MutableTally Open; // field ปกติ public readonly MutableTally Sealed_; // readonly field}
[MethodImpl(MethodImplOptions.NoInlining)]static void FeedIn(in MutableTally t) => t.Add(18000); // compile ผ่าน แต่ค่าหาย[MethodImpl(MethodImplOptions.NoInlining)]static void FeedRef(ref MutableTally t) => t.Add(18000); // ค่าติด
var t1 = new MutableTally(); FeedIn(in t1); Console.WriteLine(t1);var t2 = new MutableTally(); FeedRef(ref t2); Console.WriteLine(t2);var b = new Basket(); b.Open.Add(18000); b.Sealed_.Add(18000);Console.WriteLine($"{b.Open} | {b.Sealed_}");var list = new List<MutableTally> { new(), new() };var tmp = list[0]; tmp.Add(18000); Console.WriteLine(list[0]);CollectionsMarshal.AsSpan(list)[0].Add(18000); Console.WriteLine(list[0]);input เดียวกันทุกแถวคือ Add(18000) หนึ่งครั้งบน MutableTally ที่เพิ่งสร้างใหม่ ผลที่พิมพ์ออกมาแยกเป็นสองกลุ่มคมๆ
| input ที่วัด | ผลที่พิมพ์ออกมา |
|---|---|
FeedIn(in t1) — เรียกผ่าน in parameter | Count=0, TotalSatang=0 |
b.Sealed_.Add(18000) — เรียกผ่าน readonly instance field | Count=0, TotalSatang=0 |
| เรียกผ่าน static readonly field | Count=0, TotalSatang=0 |
((MutableTally)boxed).Add(18000) | Count=0, TotalSatang=0 |
foreach element ที่คัดลอกมาแล้วเรียก Add | Count=0, TotalSatang=0 |
var tmp = list[0]; tmp.Add(18000); — indexer ของ List<T> | Count=0, TotalSatang=0 |
FeedRef(ref t2) — เรียกผ่าน ref parameter | Count=1, TotalSatang=18000 |
b.Open.Add(18000) — field ปกติ | Count=1, TotalSatang=18000 |
| เรียกผ่าน static field ปกติ | Count=1, TotalSatang=18000 |
arr[i].Add(18000) บน array | Count=1, TotalSatang=18000 |
CollectionsMarshal.AsSpan(list)[0].Add(18000) | Count=1, TotalSatang=18000 |
สามแถวที่อันตรายที่สุดคือ readonly field, indexer ของ List<T> และ foreach เพราะทั้งสามอันเป็นสิ่งที่คนเขียนด้วยความตั้งใจดี คนที่ใส่ readonly ที่ field เพราะอยากป้องกันค่า ได้ผลตรงกันข้ามกับที่คิดพอดี ยอดรวมของตะกร้าใบนั้นกลายเป็นศูนย์ถาวร
สังเกตคู่ที่ต่างกันแค่ตัวเดียว b.Open.Add(18000) กับ b.Sealed_.Add(18000) อยู่ใน class เดียวกัน บรรทัดติดกัน ต่างกันแค่ keyword readonly ที่ตัว field แล้วผลลัพธ์ออกมาคนละเรื่อง
compiler เตือนในเคสที่คุณไม่เจอ และเงียบสนิทในเคสที่คุณเจอ
หัวข้อที่มีชื่อว่า “compiler เตือนในเคสที่คุณไม่เจอ และเงียบสนิทในเคสที่คุณเจอ”รายการ diagnostic จริงจาก
dotnet build -c Releaseซึ่งฟ้องการเขียน field ตรงๆ ทุกทาง แต่ปล่อยการเรียก method ที่เขียน field เดียวกันผ่านหมด
public struct MutableTally{ public int Count; public long TotalSatang; public void Add(long s) { Count++; TotalSatang += s; } public long Average => Count == 0 ? 0 : TotalSatang / Count; // getter ไม่ใช่ readonly public readonly long AverageTwice => Average * 2; // -> CS8656}
public static class Cases{ public static readonly MutableTally RoField;
public static void ThroughIn(in MutableTally t) => t.Add(18000); // เงียบสนิท public static void ThroughReadonlyField() => RoField.Add(18000); // เงียบสนิท public static long ReadThroughIn(in MutableTally t) => t.Average; // เงียบสนิท
// เทียบกับเคสที่ compiler ฟ้อง: // public static void E1(in MutableTally t) { t.Count = 5; } // CS8332 // public static void E2() { RoField.Count = 5; } // CS1650 // public static void E3(List<MutableTally> l) { l[0].Count = 5; } // CS1612}| input ที่วัด | diagnostic ที่ออกมาจริง |
|---|---|
t.Add(18000) ผ่าน in parameter | ไม่มีเลย |
RoField.Add(18000) ผ่าน static readonly field | ไม่มีเลย |
t.Average (getter ที่ไม่ใช่ readonly) ผ่าน in | ไม่มีเลย |
readonly member เรียก non-readonly member | warning CS8656 |
t.Count = 5 ผ่าน in parameter | error CS8332 |
RoField.Count = 5 ผ่าน static readonly field | error CS1650 |
l[0].Count = 5 ผ่าน indexer ของ List<T> | error CS1612 |
ใส่ field ที่ไม่ใช่ readonly ไว้ใน readonly struct | error CS8340 |
ข้อความเต็มของคำเตือนเดียวที่ออกมาคือ
warning CS8656: Call to non-readonly member 'MutableTally.Average.get' from a'readonly' member results in an implicit copy of 'this'.อ่านตารางนี้จากบนลงล่างแล้วจะเห็นรูปแบบที่ชัดมาก compiler ฟ้องทุกครั้งที่คุณ เขียน field ตรงๆ ผ่านทางที่มันถือว่า readonly แต่พอคุณเรียก method ที่ไปเขียน field เดียวกันนั้น มันปล่อยผ่านเงียบสนิท
สามแถวบนสุดคือสามเคสที่จะเจอใน code จริง และทั้งสามได้ 0 diagnostic ส่วน CS8656 ยิงเฉพาะตอนที่ readonly member เรียก non-readonly member ด้วยกันเอง ซึ่งเป็นรูปแบบที่พบน้อยกว่ามาก
dotnet build -c Release ที่ผ่านสะอาดไม่มี warning เลย ยังพา Count=0 จากหัวข้อที่แล้วเข้าโปรดักชันได้ทุกเส้นทางที่ผ่านสำเนา
หมายเหตุที่ต้องพูดพร้อมกัน — ทีมงานหาหน้าเอกสารทางการของ CS8656 แบบ compiler-messages ไม่พบ ได้ 404 คำเตือนข้างบนจึงยืนยันได้จากการ compile จริงบน .NET SDK 10.0.302 เท่านั้น และบทนี้ไม่แต่ง URL ขึ้นมาอ้าง
readonly struct กับ readonly member ลบสำเนาทิ้งได้จริง และเท่ากันเป๊ะ
หัวข้อที่มีชื่อว่า “readonly struct กับ readonly member ลบสำเนาทิ้งได้จริง และเท่ากันเป๊ะ”ภาษาเครื่องของ method สามตัวที่ทำงานเหมือนกันทุกอย่าง ต่างกันแค่ที่ที่วาง keyword
readonly
ทางแก้มีสองทาง คือติด readonly ที่ตัว struct ทั้งก้อน หรือติดที่สมาชิกทีละตัว คำถามคือทางไหนดีกว่า และคำตอบที่วัดได้คือ เท่ากันเป๊ะ
code ข้างล่างตัดเหลือ2 version ส่วน version ที่สามในตารางคือ SnapRo ซึ่งเป็น field ชุดเดียวกันแต่ประกาศทั้งก้อนเป็น readonly struct
public struct Snap // 112 byte, ไม่ใช่ readonly struct{ public long OrderId, CustomerId, RestaurantId, RiderId; public long PlacedAt, AcceptedAt, PickedUpAt, DeliveredAt; public long Subtotal, DeliveryFee, Discount, Total; public int Status, PaymentMethod, Rating, LineCount;
[MethodImpl(MethodImplOptions.NoInlining)] public long GrandTotalOpaque() => Subtotal + DeliveryFee - Discount;}
public struct SnapMember // เหมือนกัน แต่ member ติด readonly{ public long OrderId, CustomerId, RestaurantId, RiderId; public long PlacedAt, AcceptedAt, PickedUpAt, DeliveredAt; public long Subtotal, DeliveryFee, Discount, Total; public int Status, PaymentMethod, Rating, LineCount;
[MethodImpl(MethodImplOptions.NoInlining)] public readonly long GrandTotalOpaque() => Subtotal + DeliveryFee - Discount;}
[MethodImpl(MethodImplOptions.NoInlining)]public static long ReadOpaque_NonReadonly(in Snap s) => s.GrandTotalOpaque();[MethodImpl(MethodImplOptions.NoInlining)]public static long ReadOpaque_ReadonlyMember(in SnapMember s) => s.GrandTotalOpaque();DOTNET_JitDisasm="Read*" dotnet bin/Release/net10.0/Disasm.dll| input ที่วัด | ขนาด code ที่ JIT ปล่อย | มีสำเนา 112 byte ไหม |
|---|---|---|
ReadOpaque_NonReadonly(in Snap) | 67 byte | มี — sub rsp,120 แล้ว vmovdqu ymm0 สามคู่ กับ vmovdqu xmm0 หนึ่งคู่ ลง [rsp+0x08] แล้ว lea rdi,[rsp+0x08] |
ReadOpaque_ReadonlyStruct(in SnapRo) | 13 byte | ไม่มี — ส่ง rdi ของผู้เรียกต่อไปตรงๆ |
ReadOpaque_ReadonlyMember(in SnapMember) | 13 byte | ไม่มี — เท่ากับแถวบนเป๊ะ |
ReadInline_NonReadonly (member ที่ inline ได้) | 19 byte | ไม่มี — อ่านจาก [rdi+...] ตรงๆ |
ReadInline_ReadonlyStruct (member ที่ inline ได้) | 13 byte | ไม่มี — อ่านจาก [rdi+...] ตรงๆ |
สามแถวบนคือคำตอบของหัวข้อนี้ 67 byte ที่ประกอบด้วยการยกข้อมูล 112 byte ขึ้น stack ก่อนเรียก method ลดเหลือ 13 byte เมื่อติด readonly และ readonly structreadonly structstruct ที่ประกาศว่าทุกสมาชิกไม่แก้สถานะ ทำให้ compiler เลิกสร้าง defensive copy ทั้งหมด กับ readonly member ให้ 13 byte เท่ากันทั้งคู่ ไม่ใช่ใกล้เคียงกัน แต่เท่ากันตัวต่อตัว เลือกทางไหนก็ได้ตามความสะดวกในการอ่าน code
สองแถวล่างคือแถวที่ตำราส่วนใหญ่ไม่พูดถึง เมื่อสมาชิกนั้น inline ได้ JIT ลบสำเนาทิ้งทั้งก้อนถึงแม้จะไม่มี readonly เลย 19 byte เทียบ 13 byte และทั้งคู่อ่านค่าจาก [rdi+...] ของผู้เรียกโดยตรง ไม่มีคำสั่ง copy สักคำสั่ง
ข้อสรุปที่ใช้ได้จริงคือ defensive copy ไม่ได้ถูกจ่ายทุกครั้ง มันถูกจ่ายตอนที่ JIT inline สมาชิกนั้นไม่ได้ ซึ่งเป็นสถานการณ์ที่เกิดกับ method ใหญ่ๆ method virtual และ method ที่ข้าม assembly แต่ไม่ใช่กับ property getter สั้นๆ ที่ codebase ส่วนใหญ่เต็มไปด้วย
เหตุผลที่ควรติด readonly จึงไม่ใช่ “เพราะไม่ติดแล้วช้า” แต่เป็นเหตุผลจากหัวข้อก่อนหน้า คือมันปิดประตูรูปแบบที่ทำให้ Count กลายเป็นศูนย์เงียบๆ
ขีดจำกัดที่ต้องเขียนตรงๆ
หัวข้อที่มีชื่อว่า “ขีดจำกัดที่ต้องเขียนตรงๆ”สิ่งที่ข้อมูลชุดนี้ยังพิสูจน์ไม่ได้ และสิ่งที่วัดแล้วแต่ไม่ผ่านเกณฑ์จนต้องตัดออก
- บทนี้ไม่มีตัวเลขเวลาแม้แต่ตัวเดียว และนั่นคือความตั้งใจ ชุดวัดเวลาของบทนี้รันแล้วได้ค่าที่ไม่ซ้ำกันระหว่างรอบ มากเกินกว่าจะผ่านเกณฑ์ของคอร์สทั้งที่รันบนเครื่องเดียวกันและ pin ที่คอร์เดียวกัน ตามกติกาข้อ 2 ของบทที่ 1 ตัวเลขที่พ่วงค่าการกระจายที่ยอมรับได้ไม่ได้ ต้อง ตัดทิ้งทั้งประโยค ไม่ใช่เขียนให้เบาลง สิ่งเดียวที่บทนี้อ้างจากชุดนั้นได้คือ ลำดับความเร็วซ้ำครบทั้ง 5 รอบ บนเครื่อง i7-7700 เครื่องนี้ ในกลุ่มที่ส่ง struct 112 byte และในกลุ่ม defensive copy — ส่วน ตัวเลข ns ทุกตัวไม่ถูกชิป
- ห้ามสรุปว่า struct 24 byte ที่ส่ง by value เร็วกว่าหรือช้ากว่า reference ของ class ช่วงที่วัดได้ทับกันสนิท ผลคือ “แยกไม่ออก” ซึ่งไม่ใช่ “เท่ากัน” และไม่ใช่ “ชนะ”
- ห้ามอ้างว่าการนับ allocation มองเห็นต้นทุนการคัดลอก loop 10,000 รอบทั้งสี่แบบวัดได้ 0 B เท่ากัน ทั้งที่แบบ by value ย้ายข้อมูลไป 1.12 MB
- ห้ามใช้ขนาด code แทนต้นทุนการคัดลอกเกินราว 128 byte ที่ 256 byte JIT เปลี่ยนไปใช้
rep movsbขนาด code จึงลดลงเหลือ 80 byte ทั้งที่ข้อมูลที่ต้องย้ายมากขึ้น - ห้ามอ้างว่า defensive copy ถูกจ่ายเสมอ เมื่อสมาชิกนั้น inline ได้ JIT ลบสำเนาทิ้งหมด
- ห้ามอ้างว่า struct ชนะ class ใน
List<T>เสมอ ชนะเฉพาะตอนตั้ง capacity ที่ 0.50x–0.56x และแพ้ 1209 จาก 2000 ขนาดเมื่อไม่ตั้ง - ผลการสแกน 1209/2000 ผูกกับรูปร่างเฉพาะคู่นี้ คือ struct 24 byte เทียบ class 40 byte สิ่งที่ยกไปใช้ต่อได้คือกลไก ไม่ใช่ตัวเลข
- ตัวเลขขนาด 24 / 40 / 112 / 128 / 16 byte ผูกกับ x64 (IntPtr = 8) และ .NET 10.0.10 รวมถึง layout ของ class ที่ runtime ไม่ได้รับประกันไว้
- เลข 0 B ของ escape analysis ไม่ใช่พฤติกรรมปกติที่ app ทุกตัวได้ฟรี ที่ tiering ปกติ round 0 อ่านได้ 32 B และต้องรอถึง tier-1 หรือปิด tiering ด้วย
DOTNET_TieredCompilation=0จึงจะเห็น 0 B TieredCompilation=falseในหัวข้อ codegen เป็นสวิตช์สำหรับ สังเกต ไม่ใช่สำหรับ ชิป มันมีไว้บังคับให้ JIT ปล่อย code FullOpts ออกมาให้อ่าน ห้ามยกไปใส่ csproj ของ project ที่จะเอาไปวัดจริง
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”สิ่งที่บทนี้พิสูจน์ได้ วิธีตัดสินใจที่ใช้ได้จริง และของที่บทถัดไปจะรับช่วงต่อ
คำถาม “เปลี่ยน class เป็น struct แล้วเร็วขึ้นไหม” ตอบไม่ได้ด้วย keyword สิ่งที่วัดได้บนเครื่องนี้คือ
- struct ตัวเดียวกันไปโผล่บน heap ได้สามทาง เป็น field ของ class อ่านได้ 40 B ถูก lambda จับอ่านได้ 104 B และถูก box อ่านได้ 40 B
- ที่ 16 byte ลงมา JIT ไม่ออกคำสั่ง copy เลย คำสั่ง copy โผล่ครั้งแรกที่ 24 byte และที่ 112 byte เป็น 14 คำสั่ง
List<struct>ที่ไม่ตั้ง capacity กินเท่ากันหรือมากกว่าList<class>ใน 1209 จาก 2000 ขนาด แย่สุด +24472 B ที่ n=1025- defensive copy ทำให้
Add(18000)หายไปเงียบๆ ในทุกเส้นทางที่ผ่านสำเนา โดย compiler ให้ 0 diagnostic ในสามเคสที่พบบ่อยที่สุด readonly structกับ readonly member ให้ code 13 byte เท่ากันเป๊ะ เทียบกับ 67 byte ของ version ที่ไม่ติดreadonly
เอาไปใช้เป็นลำดับการตัดสินใจได้ตรงๆ ถามว่าใครจะถือมันไว้ก่อน ถ้าคำตอบคือ object บน heap struct ก็ขึ้น heap ตามไปด้วย · ถ้าจะส่งต่อไปเรื่อยๆ และมันใหญ่กว่า 16 byte ให้ส่งด้วย in หรือ ref · ถ้ามันเป็น struct ที่มี method แก้สถานะ ให้ทำเป็น readonly struct ไปเลย หรือถ้าทำไม่ได้ก็อย่าให้มันไปนั่งใน readonly field · และถ้าจะเปลี่ยน List<class> เป็น List<struct> ให้ตั้ง capacity ทุกครั้ง
และข้อสุดท้ายซึ่งเป็นข้อที่บทนี้ยืนยันด้วยตัวเอง คือ เครื่องมือวัดหนึ่งชิ้นตอบได้เฉพาะคำถามที่มันออกแบบมาตอบ column Allocated ตอบคำถามของบทนี้ไม่ได้ และวิธีที่ถูกต้องคือประกาศออกมาแล้วเปลี่ยนเครื่องมือ ไม่ใช่ปั้นภาพให้ดูเหมือนมีข้อสรุป
บทที่ 3 กลับมาอยู่ในสนามที่ column Allocated ตอบได้เต็มปากอีกครั้ง เรื่อง boxing ซึ่งบทนี้แตะไปหนึ่งครั้งแล้วตอนที่ (object)line อ่านได้ 40 B — และบทหน้าจะแสดงว่ามันเป็นคุณสมบัติของ code ที่ JIT ปล่อยออกมา ไม่ใช่ของซอร์สที่คุณเขียน
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- Structure types — C# reference (ms.date 2026-01-14 / อัปเดต 2026-08-07) — นิยามของ
readonly structและ readonly instance member ซึ่งเป็นสองทางที่หัวข้อสุดท้ายวัดแล้วได้ 13 byte เท่ากัน - Choosing Between Class and Struct — Framework Design Guidelines (ms.date 2008-10-22 / อัปเดต 2023-10-03) — ที่มาของกฎ “instance size under 16 bytes” หน้านี้แปะคำเตือนเรื่องความล้าสมัยไว้เอง และเส้นแบ่งที่บทนี้วัดจาก codegen ตรงกับมันพอดี
- Avoid memory allocations and data copies — C# (ms.date 2023-10-13 / อัปเดต 2026-03-30) — คำแนะนำฝั่งเอกสารเรื่องการหลีกเลี่ยงการคัดลอกข้อมูล ซึ่งใช้เกณฑ์ “three words or less”
- dotnet/runtime — ซอร์สของ
List<T>(ตรวจเมื่อ 2026-08-07) —DefaultCapacity = 4และnewCapacity = 2 * _items.Lengthซึ่งเป็นกลไกที่อธิบายผล 1209/2000 ได้ทั้งหมด - Performance Improvements in .NET 10 — .NET Blog (2025-09-10) — escape analysis กับ object stack allocation ที่เป็นเหตุผลว่าทำไม object class ที่ไม่หลุดออกนอก method ถึงอ่านได้ 0 B ที่ tier-1
เช็กความเข้าใจ — บทที่ 2
ข้อ 1 / 3loop 10,000 รอบสี่แบบ ทั้งส่ง OrderSnapshot 112 byte ทั้งก้อน ส่งด้วย in ส่ง reference ของ class และเส้นทาง defensive copy วัดได้ 0 B เท่ากันหมดทั้งสี่แบบ ข้อสรุปใดถูกต้อง