ข้าม​ไป​ยัง​เนื้อหา

boxing: ตำนาน​ที่​ตาย​แล้ว และ​ตัว​จริง​ที่​ยัง​ซ่อน​อยู่

คำ​เตือน​เรื่อง boxing ที่​ท่อง​กัน​มา​สิบ​ปี​มี​อยู่​ประมาณ​นี้ string.Format จอง object[] ทุก​ครั้ง · Enum.HasFlag box สอง​ก้อน​ต่อ​การ​เรียก​หนึ่ง​ครั้ง · การ​ใส่​ค่า​ลง​ช่อง​ของ $"..." box ทุก​ช่อง

บท​นี้​เอา​ทั้ง​สาม​ข้อ​ไป​วัด​บน .NET 10.0.10 แล้ว​ได้​คำ​ตอบ​ว่า ไม่มี​ข้อ​ไหน​จริง​ใน​รูปแบบ​ที่​ท่อง​กัน​มา สอง​ข้อ​แรก​เล็ง​ผิด​เป้า​ไป​เลย ส่วน​ข้อ Enum.HasFlag จริง​เป๊ะ​ตอน​ที่ code ยัง​ไม่​ถูก​เลื่อน​ขั้น แล้วกลาย​เป็น​ศูนย์​ทันที​ที่ JIT เลื่อน​ขั้น​ให้ ส่วน​ที่​เสีย​จริง​ยัง​เสีย​อยู่ แต่​มัน​เสียคน​ละ​ที่​กับ​ที่​คำ​เตือน​ชี้

และ​ใน​เวลา​เดียวกัน boxing ตัว​จริง​ที่​ยัง​เหลือ​อยู่​ใน​ปี 2026 ก็​ซ่อน​อยู่​ใน​ที่​ที่​แทบ​ไม่มี​ใคร​มอง​คือ parameter ที่​ประกาศ​เป็น interface, enumerator ที่​ลอด​ผ่าน IEnumerable<T> และ struct ที่​ลืม implement IEquatable<T>

boxingboxingการ​ห่อ​ค่า​ของ value type ไว้​ใน object บน heap (หัว 16 byte + payload ปัด​ขึ้น​ที​ละ 8) ซึ่ง​เป็น​คุณสมบัติ​ของ code ที่ JIT ปล่อย​ออก​มา ไม่ใช่​ของ​ซอร์ส คือ​การ​ห่อ​ค่า​ของ value type ไว้​ใน object บน heap และ​ประโยค​ที่​ทั้ง​บท​นี้​ตั้ง​อยู่​บน​มัน​คือ boxing เป็น​คุณสมบัติ​ของ code ที่ JIT ปล่อย​ออก​มา ไม่ใช่​ของ​ซอร์ส​ที่​คุณ​อ่าน ซอร์ส​บรรทัด​เดียวกัน​ให้​ตัวเลข​ได้​สอง​ค่า ขึ้น​กับ​ว่า JIT compile มัน​ถึง​ขั้น​ไหน​แล้ว

📦 kaen-food-ordering

ทั้ง​คอร์ส​เดิน​บน hot path เส้น​เดียว​ของ domain Order ที่​คอร์ส #8–#11 สร้าง​ไว้ คือ parse บรรทัด​ออร์เดอร์ → รวม​ยอด → พิมพ์​ใบเสร็จ (repo ตัวอย่าง .NET กำลัง​จัด​ทำ) บท​นี้​ใช้​ออร์เดอร์​ชุด​เดียวกัน​หก​บรรทัด​ตลอด​ทั้ง​บท เพื่อ​ให้​ทุก​ตัวเลข​เทียบ​กันได้ตรงๆ

ทุก​ตัวเลข​ใน​บท​นี้​วัด​บน​เครื่อง Intel Core i7-7700 3.60GHz (Kaby Lake) · 8 logical / 4 physical · Ubuntu 24.04.4 LTS (WSL2) · linux-x64 · .NET SDK 10.0.302 / runtime 10.0.10 · workstation non-concurrent GC · -c Release และ​ผูก​กับ x64 ที่ IntPtr = 8 byte ทั้งหมด

ไวยากรณ์ C# 11–14 ที่​ใช้​ใน​บท​นี้ (primary constructor บน struct, collection expression = [], params ReadOnlySpan<T>) คอร์ส​นี้ ใช้ โดย​ไม่​หยุด​อธิบาย ถ้า​เจอ​ของ​ที่​ไม่​คุ้น​ให้​เปิด C# 8 → 14: อะไร​เปลี่ยน​ไป​บ้าง ควบคู่​ไป

box หนึ่ง​ก้อน​มี​ขนาด​ที่​วัด​ได้ ไม่ใช่​คำ​อุปมา

หัวข้อ​ที่​มีชื่อ​ว่า “box หนึ่ง​ก้อน​มี​ขนาด​ที่​วัด​ได้ ไม่ใช่​คำ​อุปมา”

ขนาด​ของ box ที่​วัด​จริง​บน​เครื่อง​นี้ ซึ่ง​เป็น​ฐาน​ที่​ใช้​ถอดรหัส​ตัวเลข​อื่น​ทุก​ตัว​ใน​บท

ก่อน​จะ​เถียง​ว่า​อะไร box หรือ​ไม่ box ต้อง​รู้​ก่อน​ว่า​ถ้า​มัน​เกิด​ขึ้น มัน​กิน​กี่ byte วิธี​วัด​คือ​จอด​ค่า​ลง static field แล้วอ่านเดลตา

// ต้องจอดของไว้ที่ static field มิฉะนั้น escape analysis ของ .NET 10 จะย้าย box
// ไปไว้บน stack แล้วเดลตาหายไปเงียบๆ โดยไม่มี error ให้เห็น
static object? _sink;
static long Box<T>(T v) where T : struct
{
for (int i = 0; i < 200; i++) _sink = v; // อุ่นเครื่อง
long before = GC.GetAllocatedBytesForCurrentThread();
_sink = v;
return GC.GetAllocatedBytesForCurrentThread() - before;
}
input ที่​วัดUnsafe.SizeOf<T>()ขนาด box
Box(new OrderLine(2, 18000)) — struct 2 int8 B24 B
Box(new MenuKeyWithName(1042, "ผัดไทยกุ้งสด")) — int + ref16 B32 B
Box(new OrderLineFull(...)) — 3 int + 1 ref24 B40 B
Box(18000)int24 B
Box(OrderStatus.Cooking) — enum ฐาน int24 B

รูปแบบ​ที่ วัด​ได้ (ไม่​ได้​อนุมาน​จาก​สูตร) คือ​หัว 16 byte บวก payload ที่​ปัด​ขึ้น​ที​ละ 8 byte

ขนาด​อ้างอิง​ชุด​ถัด​ไป​วัด​คู่​กัน​ใน process เดียวกัน เพื่อ​ให้การ​ถอดรหัส​ตัวเลข​ใน​หัว​ข้อหลังๆ เป็น การ​วัด ไม่ใช่ การ​คำนวณ

input ที่​วัดbyte
new object[2]40 B
new object[3]48 B
new object[4]56 B
new object[6]72 B
new string('x', 5)32 B
new string('x', 10)48 B
new string('x', 11)48 B
new string('x', 21)64 B
new string('x', 24)72 B
new string('x', 28)80 B
new string('x', 31)88 B

process เดียวกัน​ยัง​ยืนยัน​ด้วย​ว่า escape analysisescape analysisการ​ที่ JIT พิสูจน์​ว่า object ไม่​หลุด​ออก​นอก method แล้ว​ย้าย​มัน​ไป​วาง​บน stack แทน heap โดย​ไม่มี warning ใด ๆ มี​แต่​ตัวเลข​ที่​หาย​ไป​เงียบ ๆ มี​อยู่​จริง​บน​เครื่อง​นี้ และ​มัน​จะ​กลืน​ตัวเลข​ที่​คุณ​ตั้งใจ​วัด​ไป​เงียบๆ

input ที่​วัดbyte
object o = satang; IntSink = (int)o; — ไม่​หลุด ไม่มี loop0 B
บรรทัด​เดียวกัน แต่​จอด o ลง static field24 B

นี่​คือ​เหตุผล​ที่​ทุก snippet วัดผล​ใน​คอร์ส​นี้​มี static object? _sink; พร้อม​คอมเมนต์​กำกับ ตาม​ที่​บท 1 ตั้ง​เป็น​วินัย​ไว้​แล้ว

method สอง​ตัว​ที่​เนื้อ​ใน​เหมือน​กัน​ทุก​ตัว​อักษร ให้​ผลลัพธ์​เชิง​ตัวเลข​เท่า​กัน แต่​อ่าน​เดลตา​ได้ 144 B เทียบ 0 B

using System.Runtime.CompilerServices;
public interface IPricedLine { int LineTotalSatang { get; } }
public readonly struct OrderLine(int qty, int unitSatang) : IPricedLine
{
public int Qty { get; } = qty;
public int UnitSatang { get; } = unitSatang;
public int LineTotalSatang => Qty * UnitSatang;
}
public static class Bench
{
static readonly OrderLine[] Lines =
[
new(2, 18000), new(1, 22000), new(3, 13500),
new(1, 8000), new(2, 17000), new(4, 9000),
];
// เสีย 144 B: struct ถูก box ตอนแปลงเป็น IPricedLine
[MethodImpl(MethodImplOptions.NoInlining)]
static int TotalViaInterface(IPricedLine line) => line.LineTotalSatang;
// เสีย 0 B: generic ถูก specialize ต่อ value type, callvirt กลายเป็น constrained call
[MethodImpl(MethodImplOptions.NoInlining)]
static int TotalViaGeneric<T>(T line) where T : IPricedLine => line.LineTotalSatang;
public static int Alloc() { int s = 0; foreach (OrderLine l in Lines) s += TotalViaInterface(l); return s; }
public static int Zero() { int s = 0; foreach (OrderLine l in Lines) s += TotalViaGeneric(l); return s; }
}
input ที่​วัดversionเครื่อง​วัด​ที่​เขียน​เองBDN column Allocatedผลลัพธ์​ที่​คืน​มา
ออเดอร์ 6 บรรทัด [(2,18000) (1,22000) (3,13500) (1,8000) (2,17000) (4,9000)]TotalViaInterface(IPricedLine)144 B144 B176500
input เดียวกันเป๊ะTotalViaGeneric<T>0 B– (ศูนย์)176500

ถอดรหัส 144 B จาก​ชิ้น​ส่วน​ที่​วัด​แยก​ไว้​แล้ว​ใน​หัวข้อ​ก่อน คือ box ของ OrderLine ซึ่ง payload 8 byte วัด​ได้ 24 B ต่อ​ก้อน คูณ​ด้วย​หก​บรรทัด​ของ input ถ้า​เปลี่ยน​เป็น struct ที่​ใหญ่​ขึ้น ราคา​ต่อ​ก้อน​ก็​ขยับ​ตาม​ตาราง​ขนาด box ข้าง​บน​ทันที

generic constraintgeneric constraintการ​ผูก parameter ชนิด​ด้วย `where T : …` เพื่อ​ให้ JIT สร้าง code เฉพาะ​ชนิด​นั้น แทนที่​จะ​รับ​เป็น interface แล้ว box ทุก​ตัว​ที่​ส่ง​เข้าไป ลบ​ตัวเลข​นั้น​ทิ้ง​ได้​เพราะ JIT สร้าง code หนึ่ง​ชุด​ต่อ1 value type ที่​เอา​ไป​ใช้​จริง การ​เรียก​ผ่าน interface จึง​กลาย​เป็นการ​เรียก​ตรง​บน​ค่าที่​ยัง​อยู่​ที่​เดิม ไม่​ต้อง​ยก​ขึ้น heap ก่อน

กรณี​นี้​ไม่​ไว​ต่อ tier วัด​ได้ 144 B เท่า​กัน​ทั้ง​ที่ tier-0 และ tier-1 และ​ใน​การ​ทดสอบ​การ​ลู่​เข้า​ที่​รัน​แปด​รอบ​ติด​กัน ตัวเลข​นี้​อ่าน​ได้ 144 B ทุกรอบ​ตั้งแต่​รอบ​แรก​ถึง​รอบ​สุดท้าย ไม่​เคย​ขยับ

เทียบเลนหน่วยความจำ

6 / 6 ออเดอร์.NET 10.0.10 · X64

บทที่ 3 · มี method คิดยอดต่อบรรทัด รับ struct เข้าไป — จะประกาศ parameter เป็นชนิดอะไร

6 / 6 ออเดอร์

แกนของกล่องนี้คือ จำนวนออเดอร์ที่ประมวลผลไปแล้ว ไม่ใช่เวลา — จังหวะของปุ่ม “เดินอัตโนมัติ” เป็นค่าคงที่ที่ตั้งไว้เพื่อให้ดูทัน ไม่ได้แทนความเร็วจริงของ code และหน้านี้ไม่แสดงเวลาที่ไหนเลย

ออเดอร์ล่าสุดที่ป้อนเข้าไป: 1047,ชาไทยเย็น,4,9000

code เดิม รับเป็น interface

struct ถูกห่อเป็นกล่องบน heap ทุกครั้งที่เรียก

code ที่ถูกวัด

public static int TotalViaInterface(IPricedLine line) => line.LineTotalSatang;

144byte ที่จองบน heap

6 ก้อน · กล่อง (box) 6 · ออเดอร์ล่าสุดเพิ่ม +24 B

heap หลังผ่านไป 6 ออเดอร์

code ใหม่ generic constraint

JIT สร้าง code เฉพาะชนิดนั้น เรียกตรงไม่ต้องห่อ

code ที่ถูกวัด

public static int TotalViaGeneric<T>(T line) where T : IPricedLine => line.LineTotalSatang;

0byte ที่จองบน heap

ไม่มีก้อนไหนถูกจองเลย · ออเดอร์ล่าสุดเพิ่ม 0 B

heap หลังผ่านไป 6 ออเดอร์

heap ว่าง — 0 ก้อน · 0 byte

ก้อนพวกนี้ ตายเกือบทันที หลังถูกจอง ราคาที่จ่ายจึงเป็น GC pressure กับ cache traffic ไม่ใช่ RAM ที่ถูกยึดค้างไว้ — กล่องนี้วัด allocation ไม่ใช่ residency

หนึ่งช่องบนตาราง heap = 8 byte (ความละเอียดจริงของ heap บน X64) · ▦ อาร์เรย์ · ▨ string · ▩ กล่อง

code สองบรรทัดนี้หน้าตาแทบเหมือนกันและให้ผลเท่ากันเป๊ะ ต่างกันแค่คำว่า IPricedLine กับ T — แต่บรรทัดบนสร้างกล่องขนาด 24 byte ทุกครั้งที่เรียก เพราะ interface ต้องการอ้างอิงไปยัง object runtime จึงคัดลอก struct ขึ้น heap ให้ก่อน ส่วนบรรทัดล่าง compiler รู้ชนิดจริงตั้งแต่ตอน JIT จึงเรียก method ตรงบนค่าที่อยู่บน stack

ราคาที่จ่าย generic ทำให้ JIT สร้าง code หนึ่งชุดต่อหนึ่ง struct ที่เอาไปใช้ — ประหยัด heap แต่จ่ายเป็นขนาด code และเวลา JIT ถ้ามี struct สิบชนิดก็สิบชุด

ตัวเลขที่วัดได้ทั้งหมดของฉากนี้ (byte)

byte ที่จองต่อออเดอร์ ของ รับเป็น interface เทียบกับ generic constraint
ออเดอร์รับเป็น interfacegeneric constraintก้อนที่เกิดขึ้น
ผัดไทยกุ้งสด240▩ box ของ OrderLine 24 B
ต้มยำกุ้ง240▩ box ของ OrderLine 24 B
ข้าวมันไก่240▩ box ของ OrderLine 24 B
ส้มตำไทย240▩ box ของ OrderLine 24 B
แกงเขียวหวานไก่240▩ box ของ OrderLine 24 B
ชาไทยเย็น240▩ box ของ OrderLine 24 B
รวมหกออเดอร์1440ผลลัพธ์ที่คำนวณได้เท่ากันทั้งสองเลน (176,500)

วนซ้ำหลายรอบแล้ววัดใหม่ทั้งหมด — ไม่ได้เอาเลขข้างบนไปคูณ

จำนวนรอบ1101001,000
รับเป็น interface1441,44014,400144,000
generic constraint0000
ประมวลผลแล้ว 6 จาก 6 ออเดอร์ · รับเป็น interface จอง 144 byte · generic constraint จอง 0 byte
ทุกตัวเลขในกล่องนี้คือผลของ GC.GetAllocatedBytesForCurrentThread() บน .NET 10.0.10 (X64) — ขนาดของแต่ละก้อนวัดจากการสร้าง object ชนิดเดียวกันจริง ๆ แล้ว script จะล้มถ้าผลรวมของก้อนไม่เท่ากับเดลตาที่วัดได้ ถ้าการจองไม่เป็นเส้นตรงตามจำนวน ออเดอร์ หรือถ้า code สอง version คำนวณผลออกมาไม่เท่ากัน · ไม่มีตัวเลขเวลาในกล่องนี้เลย เพราะจำนวน byte ที่จองซ้ำได้เป๊ะทุกรอบ แต่เวลาบนนาฬิกาไม่ซ้ำ · สร้างซ้ำได้ด้วย dotnet run -c Release --project scripts/alloc-lanes/AllocViz.csproj

คำ​บรรยาย​ภาพ: เลน​ซ้าย​คือ version ที่​รับ parameter เป็น interface เลน​ขวา​คือ version generic constraint · หนึ่ง​ขั้น​บน​แกน​นอน​คือ​หนึ่ง​บรรทัด​ออร์เดอร์ ไม่ใช่​หนึ่ง​หน่วย​เวลา · ทั้ง​สอง​เลน​คิด​ยอด​ได้ 176500 เท่า​กัน

BenchmarkDotNet วัด​เวลา​ของ2 method นี้​ไว้​ด้วย และ​ตัวเลข​ต้อง​อ่าน​พร้อม​ค่า​การกระจาย​เสมอ InterfaceParam ได้ 110.27 ns (Error 9.339 ns, StdDev 27.39 ns) ส่วน GenericConstraint ได้ 24.25 ns (Error 3.856 ns, StdDev 11.19 ns) บน​เครื่อง i7-7700 เครื่อง​เดียวกัน โดย BDN เตือน multimodal distribution กำกับ​มา​ด้วย

ตัวเลข​เวลา​คู่​นี้​ไม่ใช่​ข้อ​อ้าง​ของ​หัวข้อ​นี้ StdDev ของ​ทั้ง​สอง​แถว​ใหญ่​กว่า​หนึ่ง​ใน​สี่​ของ mean และ BDN เอง​บอกว่าการ​กระจาย​มี​หลาย​ยอด สิ่ง​ที่​หัวข้อ​นี้​อ้าง​คือ column byte ซึ่ง​เครื่อง​มือสอง​ตัว​คนละ process อ่าน​ได้​ตรง​กัน

LINQ กับ enumerator ค่า​ใช้​จ่าย​อยู่​ที่​ตัว​ไล่ ไม่ใช่​ที่​ข้อมูล

หัวข้อ​ที่​มีชื่อ​ว่า “LINQ กับ enumerator ค่า​ใช้​จ่าย​อยู่​ที่​ตัว​ไล่ ไม่ใช่​ที่​ข้อมูล”

ลำดับ​เดียวกัน​หก​สมาชิก ไล่​ด้วย​สี่​วิธี ได้ 0 B บ้าง 40 B บ้าง โดย​ข้อมูล​ไม่​เปลี่ยน​เลย

var arr = new OrderLine[6]; // เติมออเดอร์ 6 บรรทัดชุดเดิม
var list = new List<OrderLine>(arr);
IEnumerable<OrderLine> asSeq = list;
int a = 0; foreach (OrderLine l in list) a += l.LineTotalSatang; // 0 B (struct enumerator)
int b = 0; foreach (OrderLine l in asSeq) b += l.LineTotalSatang; // 40 B (box ของ enumerator)
int c = list.Sum(l => l.LineTotalSatang); // 40 B (เหตุผลเดียวกันเป๊ะ)
// เขียนมือ เลี่ยงทั้ง enumerator และ interface
int d = 0;
ReadOnlySpan<OrderLine> sp = System.Runtime.InteropServices.CollectionsMarshal.AsSpan(list);
for (int i = 0; i < sp.Length; i++) if (sp[i].Qty > 1) d += sp[i].LineTotalSatang; // 0 B
input ที่​วัดbyte
foreach บน OrderLine[6]0 B
foreach บน List<OrderLine> 6 สมาชิก0 B
foreach ผ่าน IEnumerable<OrderLine> ที่​ตัว​จริง​เป็น List40 B
foreach ผ่าน IEnumerable<OrderLine> ที่​ตัว​จริง​เป็น array32 B
list.Sum(l => l.LineTotalSatang)40 B
list.Where(...).Sum(...)72 B
arr.Where(...).Select(...).ToArray()144 B
list.OrderByDescending(...).First()128 B
list.Any(...)0 B
for + CollectionsMarshal.AsSpan(list)0 B

40 B ของ​แถว​ที่​สาม​คือ box ของ List<T>.Enumerator ซึ่ง​เป็น struct ที่​มี reference 8 + int 4 + int 4 + OrderLine 8 รวม payload 24 byte ตรง​กับ​แถว “payload 24 byte = box 40 B” ใน​ตาราง​ขนาด box พอดี ส่วน​แถว​ที่​สี่​ได้ 32 B เพราะ​ตัว​ไล่​ของ array คือ SZGenericArrayEnumerator ซึ่ง​เป็น class อยู่​แล้ว ตัวเลข​นั้น​จึง​เป็นการ​จอง object ตรงๆ ไม่ใช่ box

list.Sum(selector) ที่​ได้ 40 B เท่า​กัน​เป๊ะ จึง​บอก​ได้​ว่า​ราคา​ไม่​ได้​อยู่​ที่ lambda แต่​อยู่​ที่​การ​ที่ LINQ รับ parameter เป็น IEnumerable<T> แล้ว​ต้อง​เรียก GetEnumerator() ผ่าน interface

ตัวเลข​ทั้ง​ตาราง​มา​จาก​ลำดับ 6 สมาชิก และ​เป็นต้นทุน​ตั้ง​ต้นแบบ​คงที่ ไม่ใช่​ต้นทุน​ต่อ​สมาชิก ห้าม​คูณ​ขึ้น​ไป​เป็น​ลำดับ​หมื่น​สมาชิก บท​นี้​ไม่​ได้​วัด​ขนาด​อื่น​เลย

สอง​ที่​ที่ boxing ยัง​เกิดตรงๆ ตาม​ตำรา และ​ตัวเลข​ที่​บอกว่า​ฝั่ง​เขียน​แพง ไม่ใช่​ฝั่ง​อ่าน

[MethodImpl(MethodImplOptions.NoInlining)]
static int LogParamsObject(string tag, params object[] args)
{
int n = tag.Length; foreach (object a in args) n += a.GetHashCode(); return n;
}
[MethodImpl(MethodImplOptions.NoInlining)]
static int LogGeneric<T1, T2, T3>(string tag, T1 a, T2 b, T3 c)
=> tag.Length + a!.GetHashCode() + b!.GetHashCode() + c!.GetHashCode();
[MethodImpl(MethodImplOptions.NoInlining)]
static int LogParamsSpanInt(string tag, params ReadOnlySpan<int> args)
{
int n = tag.Length; foreach (int a in args) n += a; return n;
}
input ที่​วัดbyte
LogParamsObject("order", 1042, 2, 18000)120 B
LogGeneric("order", 1042, 2, 18000)0 B
LogParamsSpanInt("order", 1042, 2, 18000)0 B
params ReadOnlySpan<object> กับ argument ชุด​เดียวกัน72 B
LogParamsObject("order") — ไม่มี argument เลย0 B
params object[] กับ string 3 ตัว48 B
params object[] กับ int 6 ตัว216 B

ถอดรหัส​จาก​ชิ้น​ส่วน​ที่​วัด​แยก​ไว้​แล้ว 120 B ของ​แถว​แรก​คือ new object[3] 48 B บวก box ของ int 24 B สาม​ก้อน ส่วน​แถว ReadOnlySpan<object> ได้ 72 B เพราะ array หาย​ไป แต่ box ยัง​อยู่​ครบ​สาม​ก้อน และ​แถว string 3 ตัว​ได้ 48 B เพราะ​มี​แต่ array ไม่มี box เลย — string เป็น reference type อยู่​แล้ว

แถว “ไม่มี argument เลย” ได้ 0 B เพราะ compiler ส่ง Array.Empty ที่ cache ไว้ ไม่​ได้​สร้าง array ใหม่

input ที่​วัดbyte
new ArrayList(6) + Add ×6248 B
new List<OrderLine>(6) + Add ×6104 B
new Hashtable(6) + set ×6648 B
new Dictionary<int, OrderLine>(6) + set ×6304 B
อ่าน​ออก​จาก ArrayList พร้อม unbox ×60 B
อ่าน​ออก​จาก List<OrderLine> ×60 B
Hashtable lookup ด้วย key int ×6144 B
Dictionary<int, OrderLine> lookup ×60 B

ตาราง​นี้​หักล้าง​คำ​อธิบาย​ที่​ได้ยิน​บ่อย​ว่า “ArrayList แพง​เพราะ​ต้อง unbox ตอน​อ่าน” — unbox ไม่ allocate เลย อ่าน​หก​ครั้ง​ได้ 0 B ต้นทุน​อยู่​ฝั่ง​เขียน​ล้วนๆ

ข้อ​ยกเว้น​คือ Hashtable ที่​ต้อง box key ทุก​ครั้ง​ที่​ค้น 144 B ของ​หก​ครั้ง​ตรง​กับ box ของ int 24 B หก​ก้อน​ตาม​ตาราง​ขนาด box

boxing ตัว​จริง​ที่​ยัง​เหลือ​อยู่​ใน​ปี 2026 ซ่อน​อยู่​ใน comparer ที่ runtime เลือก​ให้ ไม่ใช่​ใน code ที่​คุณ​เขียน

public enum OrderStatus { Placed, Cooking, Riding, Delivered }
// comparer ที่คน "เขียนเองแบบตรงไปตรงมา" — x.Equals(y) ผูกกับ ValueType.Equals(object)
public sealed class NaiveStatusComparer : IEqualityComparer<OrderStatus>
{
public bool Equals(OrderStatus x, OrderStatus y) => x.Equals(y); // box สองก้อนต่อครั้ง
public int GetHashCode(OrderStatus o) => o.GetHashCode();
}
public sealed class FastStatusComparer : IEqualityComparer<OrderStatus>
{
public bool Equals(OrderStatus x, OrderStatus y) => (int)x == (int)y; // 0 B
public int GetHashCode(OrderStatus o) => (int)o;
}
input ที่​วัดbyte
Dictionary<OrderStatus, int> comparer ปริยาย lookup ×60 B
Dictionary<OrderStatus, int>(new NaiveStatusComparer()) lookup ×6288 B
Dictionary<OrderStatus, int>(new FastStatusComparer()) lookup ×60 B
Dictionary<Enum, int> lookup ×6144 B
status.ToString() ×6144 B
Enum.GetName(status) ×60 B
(int)status ×60 B
object o = (int?)4224 B
object o = (int?)null0 B

แถว​แรก​ได้ 0 B เพราะ runtime เลือก EnumEqualityComparer<T> ให้​เอง​เมื่อ​ไม่​ระบุ comparer (ตรวจ​ชนิด​จริง​ด้วย EqualityComparer<T>.Default.GetType().Name) คน​ที่ “ช่วย” ด้วย​การ​เขียน comparer เอง​แบบ​ตรง​ไป​ตรง​มา​จึง​ทำให้​แย่​ลง ไม่ใช่​ดี​ขึ้น

288 B ของ​หก​ครั้ง​คือ 48 B ต่อ​การ​ค้น​หนึ่ง​ครั้ง ซึ่ง​เท่ากับ box สอง​ก้อน​ตาม​ที่​คอมเมนต์​ใน code บอก​ไว้ ส่วน Dictionary<Enum, int> ที่​ประกาศ key เป็น Enum ตรงๆ เสีย 144 B คือ 24 B ต่อ​ครั้ง

ฝั่ง struct ที่​เป็น key กติกา​เดียวกัน​แต่​ผล​รุนแรง​กว่า​มาก

public readonly struct MenuKeyPlain(int restaurantId, int menuId) // 2 int ติดกัน
{ public int RestaurantId { get; } = restaurantId; public int MenuId { get; } = menuId; }
public readonly struct MenuKeyWithName(int restaurantId, string name) // มี field อ้างอิง
{ public int RestaurantId { get; } = restaurantId; public string Name { get; } = name; }
public readonly struct MenuKeyPadded(byte tier, int menuId) // มี padding
{ public byte Tier { get; } = tier; public int MenuId { get; } = menuId; }
public readonly struct MenuKeyFast(int restaurantId, int menuId) : IEquatable<MenuKeyFast>
{
public int RestaurantId { get; } = restaurantId;
public int MenuId { get; } = menuId;
public bool Equals(MenuKeyFast o) => RestaurantId == o.RestaurantId && MenuId == o.MenuId;
public override bool Equals(object? o) => o is MenuKeyFast k && Equals(k);
public override int GetHashCode() => HashCode.Combine(RestaurantId, MenuId);
}
input ที่​วัดbyte
HashSet<MenuKeyPlain>.Contains ×172 B
HashSet<MenuKeyWithName>.Contains ×1184 B
HashSet<MenuKeyFast>.Contains ×10 B
List<MenuKeyPlain>.Contains ×10 B
List<MenuKeyWithName>.Contains ×1608 B
List<MenuKeyPadded>.Contains ×1640 B

72 B ของ​แถว​แรก​ถอด​ได้​เป็น GetHashCode 24 B บวก Equals 48 B ซึ่ง​วัด​แยก​ทั้ง​สอง​ชิ้น​แล้ว​ใน process เดียวกัน

ราคา​ต่อ​การ​เทียบ​หนึ่ง​ครั้ง​ผ่าน EqualityComparer<T>.Default วัด​แยก​ได้​ตาม​นี้

input ที่​วัดbyte ต่อ​การ​เทียบ​หนึ่ง​ครั้งcomparer ที่ runtime เลือก
MenuKeyPlain — 2 int, Unsafe.SizeOf = 848 BObjectEqualityComparer<T>
MenuKeyWithName — int + string, Unsafe.SizeOf = 16152 BObjectEqualityComparer<T>
MenuKeyPadded — byte + int, Unsafe.SizeOf = 8184 BObjectEqualityComparer<T>
MenuKeyFast — 2 int + IEquatable<T>0 BGenericEqualityComparer<T>

IEquatableIEquatableinterface ที่​ทำให้ collection เทียบ struct ได้​โดย​ไม่​ตก​ไป​ที่ `ValueType.Equals` ซึ่ง​ใช้ reflection และ box ทุก​ครั้ง​ที่​เทียบ คือ​เส้น​แบ่ง​ระหว่าง2 column ขวา​ทั้ง​ตาราง ไม่ใช่​ขนาด​ของ struct ไม่ใช่​จำนวน field และ​ไม่ใช่​ว่า struct นั้น readonly หรือ​ไม่

⛔ struct 2 int ใน List อ่าน​ได้ 0 B แต่​เติม field string ตัว​เดียว​กลาย​เป็น 608 B

กฎ​ที่​ท่อง​กัน​ว่า “struct ที่​ไม่ implement IEquatable<T> จะ box ทุก​ครั้ง​ที่​เทียบ” ถูก​ครึ่ง​เดียว วัด​จริง​ได้​แบบ​นี้

  • List<MenuKeyPlain>.Contains เรียก​ครั้ง​เดียว = 0 B
  • List<MenuKeyWithName>.Contains เรียก​ครั้ง​เดียว = 608 B (ต่าง​กัน​แค่​เปลี่ยน field ที่​สอง​จาก int เป็น string)
  • List<MenuKeyPadded>.Contains เรียก​ครั้ง​เดียว = 640 B (ต่าง​กัน​แค่​เปลี่ยน field แรก​จาก int เป็น byte ซึ่ง​ทำให้​เกิด padding)

ราคา​ต่อ​การ​เทียบ​หนึ่ง​ครั้ง​ของ​สาม​ตัว​นี้​คือ 48 B, 152 B และ 184 B ตาม​ลำดับ

0 B ของ​แถว​แรก​มา​จาก​การ​ที่ runtime ตัดสิน​ได้​ว่า struct ตัว​นั้น​เทียบ​แบบ bitwise ได้ ซึ่ง​เป็น implementation detail ห้าม​สอน​เป็น​กฎ​ว่า List<T>.Contains บน struct ไม่​เสีย​อะไร การ​เติม field เดียว​ก็​พลิก​ได้​แล้ว และ​ไม่มี warning ใด​เตือน

ทาง​แก้​ที่​วัด​แล้ว​ได้ 0 B ทุก​แถว​คือ implement IEquatable<T> พร้อม override Equals(object?) และ GetHashCode() ให้​ครบ

ส่วนเกิน​ของ string.Format มี​จริง แต่​มัน​ไม่ใช่ object[] อย่าง​ที่​เชื่อ​กัน​มา และ interpolation ก็​ไม่​ได้​ฟรี​ทุก​ช่อง

var inv = System.Globalization.CultureInfo.InvariantCulture;
int id = 1042, qty = 2, satang = 18000;
// ทั้งสองบรรทัดให้ string เดียวกัน "order 1042 x2 = 18000" (21 อักขระ)
string a = string.Format(inv, "order {0} x{1} = {2}", id, qty, satang); // 136 B
string b = $"order {id} x{qty} = {satang}"; // 64 B
// string ยาว 21 อักขระ ตัวเปล่าๆ วัดได้ 64 B -> b ไม่มีส่วนเกินเลยแม้แต่ byte เดียว
string baseline = new string('x', 21); // 64 B
// ทางแก้เมื่อ format string ต้องเป็นข้อมูล เปลี่ยนตอน runtime ไม่ได้เขียนตรงๆ
static readonly System.Text.CompositeFormat Cf =
System.Text.CompositeFormat.Parse("order {0} x{1} = {2}");
string c = string.Format(inv, Cf, id, qty, satang); // 64 B
input ที่​วัดbytestring ผลลัพธ์​เปล่าๆ ที่​วัด​แยก
string.Format(inv, "order {0} x{1} = {2}", 1042, 2, 18000)136 B64 B (21 อักขระ)
$"order {id} x{qty} = {satang}" input เดียวกัน64 B64 B (21 อักขระ)
string.Format ที่ 4 argument176 B80 B (28 อักขระ)
$"..." ที่ 4 argument80 B80 B (28 อักขระ)
string.Format ที่ 6 argument216 B72 B
string.Format(inv, Cf, 1042, 2, 18000)CompositeFormat64 B64 B (21 อักขระ)
ส่ง object[] ที่​เตรียม​ไว้​แล้ว​เข้าไป80 B
FormattableString fs = $"..."152 B
StringBuilder.Append($"order {id} x{qty} = {satang}")tier-1: 0 B · tier-0: 72 B
dest.TryWrite($"...") ลง buffer stackalloc0 B

แถว StringBuilder.Append เป็น​แถว​เดียว​ของ​ตาราง​นี้​ที่​การ​วัด​จับ​ได้​ว่า​ตัวเลข​ต่าง​กัน​ระหว่าง tier จึง​ต้อง​ลง​ทั้ง​สอง​ค่า​ตาม​กฎ​ของ​บท​นี้ ที่ tier-1 ได้ 0 B ส่วน​ตอน​ที่ code ยัง​เป็น tier-0 อ่าน​ได้ 72 B ซึ่ง​คือ box สาม​ก้อน​ตาม​จำนวน​ช่อง คู่​ตัวเลข​นี้​ไป​อยู่​ใน​ตาราง​รวม​ของ​หัวข้อ tier-0 เทียบ tier-1 ข้าง​ล่าง​ด้วย

string.Format บน .NET 10 ไม่​จอง object[] อีก​แล้ว ส่วนเกิน​คือ box ล้วนๆ

เอา​ส่วนเกิน​มา​วาง​เทียบ​กับ​ตาราง​ขนาด​ของ box ใน​หัวข้อ​แรก แล้ว​มัน​ตรง​กัน​พอดี​ทุก​กรณี

  • ที่ 3 argument 136 B ลบ string ผลลัพธ์ 64 B เหลือ 72 B = box ของ int 24 B สาม​ก้อน​พอดี
  • ที่ 4 argument 176 B ลบ string ผลลัพธ์ 80 B เหลือ 96 B = สี่​ก้อน​พอดี ขณะ​ที่ new object[4] วัด​ได้ 56 B ซึ่ง​ไม่​ปรากฏ​ใน​ตัวเลข​เลย
  • ที่ 6 argument 216 B = string 72 B บวก 144 B = หก​ก้อน​พอดี ขณะ​ที่ new object[6] วัด​ได้ 72 B ซึ่ง​ก็​ไม่​ปรากฏ​อีก

เหตุผล​คือ .NET 10 มี overload string.Format(IFormatProvider, string, ReadOnlySpan<object>) (ยืนยัน​ด้วย reflection บน typeof(string).GetMethods()) compiler จึง​ไม่​ต้อง​สร้าง array ให้​ก่อน

ทาง​แก้​จึง​คือ​กำจัด box ไม่ใช่​กำจัด array และ​ทาง​แก้​ที่​วัด​แล้ว​ได้ 64 B เท่ากับ interpolation เป๊ะ​คือ CompositeFormat ซึ่ง​ใช้ได้​แม้ format string จะ​เป็น​ข้อมูล​ที่​เปลี่ยน​ตอน runtime

ฝั่ง interpolation ก็​ไม่​ได้​ฟรี​ทุก​ช่อง interpolated string handlerinterpolated string handlerโครง​ที่ compiler แปลง `$"…"` ไป​เรียก​แทน​การ​ต่อ string ทำให้​ช่อง​ที่​เป็น int ไม่​เสีย​ส่วนเกิน แต่​ยัง box struct ที่​ไม่ implement `ISpanFormattable` รู้จัก​ชนิด​พื้นฐาน​และ​ชนิด​ที่ implement ISpanFormattable แต่​ชนิด​ที่​มัน​ไม่รู้จัก​ยัง​ต้อง box ราคา​ต่อ​ช่อง​วัด​ได้​ตาม​นี้ โดย​หัก​ค่า​ฐาน​ของ string ผลลัพธ์​ที่​วัด​แยก​แล้ว​ออก​ทุก​แถว

input ที่​วัด — ชนิด​ของ​ช่อง​ใน $"..."ส่วนเกิน​ต่อ​ช่อง
int+0 B
enum+0 B
struct ที่ implement ISpanFormattable+0 B
struct ที่ implement IFormattable อย่าง​เดียว+32 B
struct ที่ override ToString() แต่​ไม่ implement อะไร​เลย+32 B
struct ที่ ไม่ override ToString()+24 B

สอง​แถว +32 B ไม่ใช่ box แต่​เป็น string กลาง​ทาง​ที่​เกิด​ขึ้น​ก่อน​จะ​ถูก​คัด​ลอก​ลง​ผลลัพธ์ ส่วน​แถว​สุดท้าย +24 B คือ box หนึ่ง​ก้อน​เป๊ะ ตัวอย่าง​จริง​คือ $"total {plainNoTs}" ที่​ช่อง​เป็น struct ซึ่ง​ไม่ override ToString() วัด​ได้ 112 B ขณะ​ที่ string ผลลัพธ์ 31 อักขระ​วัด​ได้ 88 B

จุด​ที่​การ​วัด boxing หลอก​คน​วัด​ได้​แนบเนียน​ที่สุด และ​เหตุผล​ที่​บท​นี้​ต้อง​ลง​เลข​สอง​ค่า​เสมอ

tiered compilationtiered compilationกลไก​ที่ JIT compile method แบบ​เร็ว-หยาบ​ก่อน (tier-0) แล้ว​ค่อย compile ใหม่​แบบ​เต็ม (tier-1) เมื่อ​ถูก​เรียก​บ่อย​พอ​และ​ตัว​จับ​เวลา​เดิน​ครบ ทำให้ code ชุด​เดียวกัน​รายงาน allocation คนละ​ค่า​ได้ ทำให้ JIT compile method แบบ​เร็ว​และ​หยาบ​ก่อน แล้ว​ค่อย compile ใหม่​แบบ​เต็ม​เมื่อ​ถูก​เรียก​บ่อย​พอ และ optimizing JIT เท่านั้น​ที่​ลบ box บาง​ก้อน​ทิ้ง​ได้ วิธี​จับ​สอง​สถานะ​จึง​ต้อง​เขียน​เครื่อง​วัด​สอง​ตัว

// เรียกแค่ 3 ครั้ง: code ยังอยู่ tier-0 (quick JIT ไม่ optimize)
static long ColdDelta(Action body)
{
body(); body();
long before = GC.GetAllocatedBytesForCurrentThread();
body();
return GC.GetAllocatedBytesForCurrentThread() - before;
}
// เรียกซ้ำสลับกับ Thread.Sleep เพื่อเปิดช่องให้ background JIT compile tier-1 เสร็จ
static void Settle(Action body)
{
for (int i = 0; i < 200; i++) body(); Thread.Sleep(150);
for (int i = 0; i < 200; i++) body(); Thread.Sleep(150);
for (int i = 0; i < 100; i++) body();
}
input ที่​วัดtier-0tier-1
flags.HasFlag(Rush) ×6288 B0 B
StringBuilder.Append($"order {id} x{qty} = {satang}")72 B0 B
$"{satSpan}"72 B48 B
$"{sat}"104 B80 B
$"{sat:N0}"112 B88 B
$"{satSpan:N0}"72 B48 B
object o = x; o.ToString()56 B32 B
int[8] ที่​ไม่​หลุด​ออก​จาก method56 B0 B
TotalViaInterface ×6 บรรทัด (ฉาก​หลัก​ของ​บท​นี้)144 B144 B

สี่​แถว​ที่​เป็น $"..." ช่อง​เดียว มี​ส่วน​ต่าง​ระหว่าง2 tier เท่ากับ 24 B พอดี ซึ่ง​คือ box หนึ่ง​ก้อน​ตาม​ตาราง​ใน​หัวข้อ​แรก ส่วน​แถว StringBuilder.Append ต่าง​กัน 72 B เพราะ string นั้น​มี​สาม​ช่อง จึง​เป็น box สาม​ก้อน

flowchart TD
    A[ซอร์สบรรทัดเดียวกัน เรียก flags.HasFlag Rush หกครั้ง] --> B{JIT compile ถึงขั้นไหนแล้ว}
    B -- ยังเป็น tier-0 --> C[อ่านเดลตาได้ 288 byte]
    B -- เลื่อนขั้นเป็น tier-1 แล้ว --> D[อ่านเดลตาได้ 0 byte]
    C --> E[process อายุสั้น cold path และ serverless จ่ายเลขนี้จริง]
    D --> F[app ที่รันยาวจ่ายเลขนี้]
    G[ซอร์สอีกบรรทัด parameter เป็น interface หกบรรทัดออร์เดอร์] --> H[อ่านเดลตาได้ 144 byte]
    H --> I[วัดได้ 144 byte ทุกรอบตั้งแต่รอบแรกถึงรอบสุดท้าย ไม่ขึ้นกับ tier]

คำ​บรรยาย​ภาพ: call site สอง​แบบ​ใต้ tiered compilation เดียวกัน · Enum.HasFlag ให้​คำ​ตอบ​คนละ​ค่า​ระหว่าง2 tier จึง​ต้อง​อ้าง​สอง​เลข​เสมอ ส่วน parameter ที่​เป็น interface ให้​ค่า​เดียว​ทุก tier จึง​เป็น​ตัวเลข​ที่​ใช้​เป็น​ข้อ​สรุป​ของบทได้ตรงๆ

⛔ ตำนาน Enum.HasFlag box สอง​ครั้ง — เท็จ​ที่ tier-1 และ​จริง​เป๊ะ​ที่ tier-0

code เดียวกัน เครื่อง​เดียวกัน ไบนารี​เดียวกัน ให้​สอง​คำ​ตอบ input คือ​การ​เรียก flags.HasFlag(Rush) หก​ครั้ง​ติด​กัน

  • tier-0: 288 B คือ 48 B ต่อ​การ​เรียก​หนึ่ง​ครั้ง ซึ่ง​เท่ากับ box สอง​ก้อน ตรง​กับ​ที่​คำ​เตือน​เก่า​บอก​ไว้​เป๊ะ
  • tier-1: 0 B เท่ากับ (flags & Rush) != 0 ที่​วัด​ได้ 0 B ใน​ทุก​กรณี

ทดสอบ​การ​ลู่​เข้า​ภาย​ใต้ tiering ปริยาย HasFlag ได้ 288 B ใน​สาม​รอบ​แรก แล้วกลาย​เป็น 0 B ตั้งแต่​รอบ​ที่​สี่​เป็นต้น​ไป​และ​คงที่ ซึ่ง​อยู่​ที่​ราว 60,000 การ​เรียก​บวก Thread.Sleep ระหว่าง​รอบ​บน​เครื่อง​นี้

ห้าม​เลือก​อ้าง​เลข​เดียว เลข 288 B ไม่ใช่​เกร็ด​ทาง​ทฤษฎี process อายุ​สั้น cold path และ serverless จ่าย​เลข​นั้น​จริง ส่วน​เลข 0 B ก็​ไม่ใช่​การ​โกง มัน​คือ​เลข​ที่ app ซึ่ง​รัน​ยาว​จ่าย​จริง​เช่น​กัน คำ​ตอบ​ที่​ซื่อสัตย์​ต่อ​ข้อมูล​คือ “ขึ้น​กับ​ว่า code นั้น​ถูก​เลื่อน​ขั้น​แล้ว​หรือ​ยัง”

สิ่ง​ที่​ข้อมูล​ชุด​นี้​ยัง​พิสูจน์​ไม่​ได้ และ​จะ​ไม่​ถูก​อ้างในบทถัดๆ ไป

  • ห้าม​อ่าน​ตัวเลข​ทั้ง​บท​เป็น​ตัวเลข​ของ ”.NET ทั่วไป” ทุก​แถว​ผูก​กับ linux-x64, SDK 10.0.302 / runtime 10.0.10, i7-7700 8 cores บน WSL2, workstation non-concurrent GC, TieredPGO ปิด, -c Release · ขนาด box ที่​หัว 16 byte เป็น​ของ x64 บน 32-bit หรือ NativeAOT ตัวเลข​ชุด​นี้​เปลี่ยน และ​บท​นี้​ไม่​ได้​วัด​สอง​อย่าง​นั้น
  • ห้าม​อ้าง​ว่า “ตัวเลข​นี้​ดี​ขึ้น​จาก .NET 8” บท​นี้​ไม่​ได้​วัด .NET 9 หรือ​เก่า​กว่า​เลย​แม้แต่​รัน​เดียว พูด​ได้​แค่ “บน .NET 10.0.10 วัด​ได้​เท่า​นี้”
  • ตัวเลข​เวลา​คู่​เดียว​ของ​บท​นี้​ใช้​เป็น​ข้อ​อ้าง​หลัก​ไม่​ได้ BDN รัน​เดียว​บน WSL2 ที่​เตือน multimodal และ StdDev ราว​หนึ่ง​ใน​สี่​ของ mean ข้อ​สรุป​ทั้ง​บท​อ่าน​จาก column byte
  • ห้าม​สอน​ว่า “List<T>.Contains บน struct ไม่​เสีย​อะไร” เป็น​กฎ 0 B ขึ้น​กับ​การ​ที่ runtime ตัดสิน​ว่า struct นั้น​เทียบ​แบบ bitwise ได้ ซึ่ง​เป็น implementation detail ที่​การ​เติม field เดียว​ก็​พลิก​ได้
  • เลข​ทางการ​ของ​บท​นี้​วัดใต้ DOTNET_TieredCompilation=0 ห้าม​นำ​เสนอ​เป็น “สิ่ง​ที่ app ของ​คุณ​เจอ​เสมอ” tiering ปริยาย​ลู่​เข้า​เลข​เดียวกัน​ได้ แต่​ใช้​ราว 60,000 การ​เรียก และ​ห้าม​ใส่​สวิตช์​นั้น​ลง project ที่​ผู้​อ่าน​จะ​ลอก​ไป​วัด​ต่อ
  • ตัวเลข LINQ ทั้ง​ชุด​มา​จาก​ลำดับ 6 สมาชิก และ​เป็นต้นทุน​ตั้ง​ต้นแบบ​คงที่ ห้าม extrapolate ขึ้น​ไป​เป็น​ลำดับ​ที่​ใหญ่​กว่า
  • การ​ถอดรหัส​ตัวเลข​เป็น “กี่ box บวก string เท่าไร” ใน​บท​นี้ ยืน​บน​ชิ้น​ส่วน​ที่​วัด​แยก​จริง​ทุก​ชิ้น ถ้า​อยาก​ได้การ​ถอดรหัส​ที่​ไม่​อยู่​ใน​ตาราง​เหล่า​นี้ ต้อง​กลับ​ไป​วัด​ใหม่ ห้าม​คำนวณ​จาก​สูตร
  • ยัง​ไม่​ได้​วัด NativeAOT, ARM64, server GC, source generator ของ logging, เส้นทาง async และ Dictionary ที่​มี hash collision จริง

สาม​ข้อ​ที่​ตาย​แล้ว สาม​ที่​ที่​ยัง​เสีย​จริง และ​กฎ​เดียว​ที่​ใช้​ตัดสิน​ทั้งหมด

คำ​เตือน​เก่า​ที่​บท​นี้​วัด​แล้ว​พบ​ว่า​เล็ง​ผิด​เป้า​บน .NET 10.0.10

  1. string.Format ไม่​จอง object[] อีก​แล้ว ส่วนเกิน​คือ box ล้วนๆ (72 B ที่ 3 argument, 96 B ที่ 4, 144 B ที่ 6) ทาง​แก้​คือ CompositeFormat ที่​วัด​ได้ 64 B เท่ากับ interpolation เป๊ะ
  2. $"..." ที่​ช่อง​เป็น int ไม่มี​ส่วนเกิน​เลย 64 B ของ​มัน​คือ string ผลลัพธ์ 21 อักขระ​พอดี
  3. Enum.HasFlag = 0 B เมื่อ code ถูก​เลื่อน​ขั้น​แล้ว และ = 288 B ต่อ​หก​การ​เรียก​เมื่อ​ยัง​เป็น tier-0 ทั้ง​สอง​เลข​จริง​ทั้ง​คู่

boxing ตัว​จริง​ที่​ยัง​เหลือ​อยู่ และ​วัด​ได้​ใน​บท​นี้

  • parameter ที่​ประกาศ​เป็น interface — ออร์เดอร์ 6 บรรทัด​เสีย 144 B ทุก tier ส่วน generic constraint เสีย 0 B โดย​เนื้อ method เหมือน​กัน​ทุก​ตัว​อักษร
  • enumerator ที่​ลอด​ผ่าน IEnumerable<T> — 40 B ต่อ​การ​ไล่​หนึ่ง​ครั้ง ไม่​ว่า​จะ​เขียน​เป็น foreach หรือ list.Sum(...)
  • struct ที่​ลืม IEquatable<T>List<T>.Contains ครั้ง​เดียว​ขยับ​จาก 0 B เป็น 608 B ด้วย​การ​เติม field string ตัว​เดียว และ​เป็น 640 B เมื่อ​มี padding

กฎ​ที่​ใช้​ตัดสิน​ทั้ง​บท​มี​ข้อ​เดียว อย่า​เชื่อ​คำ​เตือน​ที่​ไม่มี​ตัวเลข​กำกับ และ​อย่า​เชื่อ​ตัวเลข​ที่​ไม่​บอกว่า​วัด​ที่ tier ไหน

บท 4 เปลี่ยน​คำถาม​จาก “ค่า​ถูก​ห่อ​ไหม” เป็น “ต้อง​คัด​ลอก​ข้อมูล​ไหม” เพราะ​เมื่อ box หมด​ไป​แล้ว ผู้ร้าย​ราย​ใหญ่​ที่​เหลือ​ใน hot path ของ parse บรรทัด​ออร์เดอร์​คือ string ใหม่​ที่ string.Split คืน​มา​ทุก field และ​เครื่องมือ​ที่​จะ​ลบ​มัน​คือ Span<char> กับ stackalloc ซึ่ง​มา​พร้อม​ข้อ​จำกัด​ที่ compiler บังคับ ไม่ใช่​คำ​แนะนำ


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้

บท​นี้​อิง​ต้นทาง​ที่​ลง​วัน​ที่​กำกับ อ่าน​ต่อ​ได้​โดยตรง:

เช็กความเข้าใจ — บทที่ 3

ข้อ 1 / 3

method สองตัวมีเนื้อในเหมือนกันทุกตัวอักษร ต่างกันแค่ parameter ตัวหนึ่งประกาศเป็น IPricedLine อีกตัวประกาศเป็น T พร้อม where T : IPricedLine เมื่อไล่ออร์เดอร์ 6 บรรทัดชุดเดียวกัน วัดได้ 144 B กับ 0 B ข้อใดอธิบายตัวเลข 144 B ได้ตรงกับที่วัดจริง