commit นั้นชื่อ “modernize”
ทีมเพิ่งย้ายบริการสั่งอาหารออกจาก .NET Framework มาได้สำเร็จ แล้วใน commit เดียวกันนั้นก็เปิดของใหม่พร้อมกันสามอย่าง:
<Nullable>enable</Nullable> ทั้ง solution
แปลง class ที่หน้าตาเหมือน DTO ให้เป็น record ทุกตัวที่หาเจอ
รัน dotnet format ทับทั้ง repo เพื่อย้ายไปใช้ namespace แบบบรรทัดเดียว
PR ที่ออกมามีสี่ร้อยกว่า file และคำเตือน null อีกพันกว่าข้อ
ไม่มีใครรีวิวของแบบนั้นได้ ใน file เดียวกัน บรรทัดที่ขยับเพราะการเยื้องหายไปหนึ่งระดับ อยู่ปนกับบรรทัดที่ ความหมายเปลี่ยน โดยไม่มีอะไรแยกให้
คนรีวิวเลื่อนดูสาม10 file แรก ไม่เจออะไรน่ากลัว แล้วกด Approve เพราะไม่มีทางอื่นให้ทำ
สามสัปดาห์ต่อมา bug ก็โผล่ แต่ไม่ใช่เรื่อง null และไม่ใช่เรื่องการจัดรูปแบบ
cache ในหน่วยความจำที่ใช้ DTO ตัวหนึ่งเป็น key เริ่มคืนข้อมูลของลูกค้าผิดคน เพราะ class ตัวนั้นกลายเป็น record ไปแล้ว
และ record เทียบเท่ากันด้วย ค่าของสมาชิก ไม่ใช่ด้วยตัวตนของ object การเปลี่ยนคำเดียวบนบรรทัดเดียวจึงเขียน Equals กับ GetHashCode ของชนิดนั้นใหม่ทั้งชุด
แล้วมันก็ถูกกลบอยู่ใน commit เดียวกับการย้ายปีกกาของทั้ง repo
ปัญหาไม่ได้อยู่ที่ feature ตัวไหนเลยสักตัว ทั้งสามอย่างใน commit นั้นเป็นของดีทั้งหมด
ปัญหาคือมันถูกเปิดพร้อมกันทั้งที่เป็นของคนละชนิดกัน และสิ่งที่แยกมันออกจากกัน ไม่ใช่ว่าอันไหนใหม่กว่า ดังกว่า หรือพิมพ์สั้นกว่า
ธีสิสของหน้านี้
C# 8 ถึง 14 เพิ่ม feature ที่ Microsoft ระบุชื่อไว้เป็นบุลเล็ตรวม 94 ตัว ตลอด7 version แต่มันไม่ได้เป็นของชนิดเดียวกัน
คำถามที่แยกมันออกจากกันมีอยู่ ข้อเดียว ตั้งแต่ต้นจนจบหน้านี้:
“feature นี้เปลี่ยนสิ่งที่ compiler พิสูจน์ หรือ บังคับ ให้เราได้หรือเปล่า”
คำตอบแยก feature ออกเป็นสามชั้น:
ชั้นออกแบบ — ตอบว่า เปลี่ยน และต้องเขียนออกมาเป็นประโยคได้ว่าความจริงข้อใหม่คืออะไร ถ้าเขียนไม่ออก แปลว่ามันไม่ใช่
ชั้นการพิมพ์ — ตอบว่า ไม่เปลี่ยน code สั้นลงหรืออ่านง่ายขึ้น แต่ความจริงที่มันบอกเท่าเดิมทุกประการ
ชั้นประสิทธิภาพ/interop — ไม่ได้เถียงกันเรื่องการพิสูจน์ตั้งแต่แรก จึงไม่ใช้คำถามข้างบน มันตัดสินด้วยต้นทุนตอนรัน และการคุยกับ code ที่ไม่ได้จัดการหน่วยความจำให้
พอถามคำถามนี้กับทั้ง 94 รายการ ได้สามกองที่มีกติการีวิวคนละขั้ว:
ชั้นออกแบบ — 17 ตัวจาก 94 (18.1%) — เปิดทีละตัว พร้อมบันทึกการตัดสินใจ
ชั้นการพิมพ์ — 59 ตัว (62.8%) — ใช้ได้เลยเมื่อถึงคราว เฉพาะตัวที่เป็นกฎจัดรูปแบบล้วน ๆ ถึงจะกวาดทั้ง repo ได้ใน commit เดียว
ชั้นประสิทธิภาพ/interop — 18 ตัว (19.1%) — คุณ มีสิทธิ์เพิกเฉย จนกว่าจะมีผลวัดมาขอ
ถ้าแยกสามกองนี้ออกจากกันได้ commit “modernize” ข้างบนจะกลายเป็นของที่รีวิวได้
กับ feature ส่วนใหญ่ คำถามนี้ตอบง่าย ที่เหลือคือสามรูปแบบที่หลอกคนซ้ำ ๆ และสองอันในนั้นเคยหลอกหน้านี้มาแล้วจริง ๆ
เริ่มจากสองตัวที่ตอบง่ายก่อน:
ที่เหลือคือสามรูปแบบที่หลอกคนได้ซ้ำ ๆ ทั้งสามอันถูกเถียงกันจริงตอนจัดชั้น file ข้อมูลของหน้านี้ และสองอันแรกถึงขั้นเคยจัดผิดมาแล้ว
ตัวอย่างที่ชัดที่สุดคือ module initializers ของ C# 9 ช่อง “code เดิม” ของมันในคลังท้ายหน้าไม่มีอะไรให้เทียบเลย เพราะก่อนหน้านั้นไม่มีวิธีเขียนสิ่งนี้แม้แต่วิธีเดียว
ฟังดูเหมือนของใหญ่ทันที แต่พอเอาคำถามไปถามจริง ๆ:
มันพิสูจน์อะไรให้เราเพิ่มไหม — ไม่
มันบังคับอะไรที่เมื่อวานไม่บังคับไหม — ไม่
สิ่งที่มันเพิ่มคือ ความสามารถตอนรัน คือมีที่ให้แขวน code ที่ต้องทำงานก่อนใคร ความแปลกใหม่ของไวยากรณ์ กับน้ำหนักของสิ่งที่ compiler รับประกัน เป็นคนละเรื่องกัน
ในหน้านี้มี7 feature ที่ช่อง “code เดิม” ว่างเปล่า และมันกระจายอยู่ทั้งสามชั้น:
ref fields — ชั้นออกแบบ แต่เป็นด้วยเหตุผลคนละเรื่องกับความว่างของช่องนั้นโดยสิ้นเชิง คือมันปฏิเสธโปรแกรมของคุณด้วย CS8374 เมื่ออ้างอิงที่เก็บไว้จะอยู่นานเกินที่เก็บที่มันชี้ไป
nint/nuint — ชั้นการพิมพ์
[SkipLocalsInit] — ชั้นประสิทธิภาพ
ช่องว่างนั้นจึงไม่ได้ตัดสินอะไรให้สักตัวเดียว
pattern and ของ C# 9 ทำให้ช่วงที่เป็นไปไม่ได้อย่าง > 20 and < 5 โดน compiler ฟ้อง ส่วน && ที่เขียนเรื่องเดียวกันไม่มีใครฟ้อง
ฟังแล้วเข้าเกณฑ์ชั้นออกแบบเป๊ะ ๆ จนกระทั่งไปเจอสองข้อเท็จจริงนี้:
ตัววิเคราะห์ pattern ที่เป็นไปไม่ได้ตัวนั้นมีมาก่อน C# 9 — switch ที่ไม่มี and, or หรือ not อยู่เลยสักตัว ก็ยังได้ CS8510 ที่ LangVersion 8 อยู่ดี and ไม่ได้พาตัวตรวจตัวใหม่มา มันแค่ยื่นรูปประโยคใหม่ให้ตัวตรวจตัวเดิม
pattern เชิงเปรียบเทียบใช้ได้กับค่าคงที่ที่คนเขียนพิมพ์เองตอน compile เท่านั้น — ค่าที่อ่านมาตอนรันใช้ไม่ได้เลย (ได้ CS9135)
สิ่งที่รหัสนั้นจับได้จึงเป็น การพิมพ์ผิด ในบรรทัดที่คนเขียนเพิ่งพิมพ์ไปเมื่อครู่ ไม่ใช่ข้อผิดพลาดของการออกแบบที่จะรอดไปถึงโปรดักชัน
ข้อนี้3 version แรกไม่บังคับให้เขียน แต่พอเดินมาถึง C# 11 ขึ้นไปแล้วเลี่ยงไม่ได้ เพราะมี3 feature ที่สิ่งที่มันทำคือ เลิกปฏิเสธ ไม่ใช่ เริ่มรับประกัน :
ทั้งสามตัว “เปลี่ยนสิ่งที่ compiler บังคับ” จริงตามตัวหนังสือ และไม่มีตัวไหนควรอยู่ชั้นออกแบบเลยสักตัว
กฎจึงมี ทิศทาง ติดมาด้วย — ชั้นออกแบบคือชั้นที่ compiler พิสูจน์หรือบังคับได้ มากขึ้น
ตัวอย่างที่คมที่สุดคือ struct ที่ไม่ต้องเขียนค่าให้ครบ ฟังดูเหมือน compiler ฉลาดขึ้น จนกระทั่งถามว่ามันพิสูจน์อะไร
คำตอบคือมันไม่ได้พิสูจน์ว่า field นั้นถูกเซ็ต — มันเซ็ตให้ เป็นศูนย์ และวัดแล้วว่าที่ LangVersion 11.0 ไม่มีคำเตือนโผล่มาสักตัว
คนอ่าน code จึงไม่ได้รับสัญญาณอะไรเลยว่ามี field ถูกเติมค่าให้เงียบ ๆ นั่นคือความสะดวก ไม่ใช่การรับประกัน
ข้อทิศทางนี้ไม่ได้ทำให้ของเก่าขยับสักตัว กรณีถอนข้อห้ามของ C# 10 ยังเป็นชั้นออกแบบอยู่เหมือนเดิม ด้วยเหตุผลที่ทิศทางรักษาไว้พอดี
คือมันหยุดปฏิเสธเพราะมัน สรุปข้อเท็จจริงใหม่ได้เอง ว่าตัวแปรถูกกำหนดค่าไปแล้ว ต่างจากสามตัวข้างบนที่ไม่มีตัวไหนสรุปอะไรใหม่เลย
หกเดือนหลังจาก “modernize” ถูกผ่าออกเป็น3 commit ที่รีวิวได้ ทีมเดิมขึ้น .NET 10
คราวนี้แผนดีขึ้นมาก — ขยับ runtime ก่อน ยังไม่แตะ version ภาษา build เขียว โปรดักชันเงียบสนิท
แล้วสัปดาห์ถัดมา PR ชื่อ “ตาม version ภาษาให้ทัน” ก็เข้ามา มีของสามอย่างใน commit เดียว:
property get; set; สิบสองตัวถูกเขียนใหม่ให้เรียกที่เก็บค่าของตัวเองว่า field
generic helper ทุกตัวใน project ยูทิลิตี้ถูกเติม where T : allows ref struct ต่อท้าย เพราะ “ไม่เห็นเสียอะไร”
library ภายในที่อีกสามทีมอ้างถึงอยู่ เปลี่ยนลายเซ็นจาก params OrderLine[] เป็น params ReadOnlySpan<OrderLine>
ทุกอย่าง compile ผ่าน ไม่มีคำเตือนสักตัว test เขียวครบ และนั่นคือปัญหาทั้งหมด เพราะการจัดชั้นทำงานถูกต้องทุกตัวอักษร แล้วก็ไม่มีใครดูมัน:
field กับ allows ref struct เป็น ชั้นออกแบบทั้งคู่ คือกองที่กติกาบอกว่าให้เปิดทีละตัวพร้อมเหตุผลที่เขียนไว้
params ที่รับ collection ได้เป็น ชั้นประสิทธิภาพ คือกองที่ต้องรอผลวัดมาขอ
PR เดียวนี้จึงละเมิดกติกาสามข้อพร้อมกัน โดยไม่มี error สักตัวมาบอก และผลของแต่ละข้อก็ต่างกันคนละแบบ
field บน property ที่ get; set; เปล่า ๆ ไม่ได้อะไรกลับมานอกจากตัวอักษร ส่วน property ตัวเดียวใน repo ที่มีเงื่อนไขตรวจค่าอยู่ใน setter จริง ๆ กลับถูกข้าม เพราะ “มันมี field รองรับอยู่แล้ว” ทั้งที่นั่นคือจุดเดียวที่ feature นี้มีของให้
allows ref struct คือการที่ method สละสิทธิ์ของตัวเอง พอเติมบรรทัดนั้นแล้ว code ข้างในจะ box T ไม่ได้ เก็บ T ลง field ไม่ได้ และส่ง T ต่อเข้า generic ตัวอื่นที่ไม่ได้ประกาศข้อเดียวกันไม่ได้อีกเลย
วันนี้ไม่มีอะไรพัง เพราะยังไม่มี helper ตัวไหนทำสามอย่างนั้น มันจะไปพังกับคนที่เพิ่ม code หนึ่งบรรทัดในเดือนมีนาคม
การเปลี่ยน params ทำให้จุดเรียกทุกจุดเหมือนเดิม ทุกตัวอักษร ซึ่งเป็นเหตุผลที่มันดูปลอดภัย และมันคือ binary breaking change สำหรับแอสเซมบลีทุกตัวที่ compile ไว้กับลายเซ็นเดิมแล้ว
บทเรียนของ2 commit นี้ต่างกันหนึ่งชั้น commit แรกเป็นเรื่องของการ ไม่มี เกณฑ์ ส่วน commit ที่สองเป็นเรื่องของการมีเกณฑ์แล้วไม่ใช้
และอันหลังคือเหตุผลที่หน้านี้จบด้วยลำดับการเปิดใช้ ไม่ได้จบด้วยตารางป้ายชั้น
flowchart TB
F["A bullet on the release-notes page"]
F --> Q{"Does the compiler now prove or enforce MORE than it did before"}
Q -->|"Yes - and the new guarantee fits in one sentence"| D["Design layer - 17 of 94"]
Q -->|"No - same truth, fewer keystrokes"| T["Typing layer - 59 of 94"]
Q -->|"Question does not apply - runtime cost or interop"| P["Perf / interop layer - 18 of 94"]
D --> DA["One PR each, one written decision each"]
T --> TA["Use it when you get there - only the purely mechanical rules go repo-wide"]
P --> PA["Ignore it until a measurement asks for it"]
N["Trap 1 - no prior syntax to compare against"] -.->|"decides nothing on its own"| Q
C["Trap 2 - one more diagnostic code"] -.->|"decides nothing on its own"| Q
L["Trap 3 - a restriction was lifted, not added"] -.->|"decides nothing on its own"| Q
ภาพ 1 — คำถามข้อเดียวที่ตัดสินทั้ง 94 รายการ · เส้นประสามเส้นคือกับดักสามอันข้างบน ทุกเส้นชี้กลับไปที่คำถาม ไม่ได้ชี้ไปที่ชั้นใดชั้นหนึ่ง เพราะไม่มีข้อไหนตัดสินแทนคำถามนี้ได้
เอาเกณฑ์เดียวกันไปใช้กับทั้ง7 version เรียงกัน สิ่งที่โผล่ออกมาไม่ใช่ป้ายชั้น 94 ใบ แต่เป็นเส้นแนวโน้มที่อ่านได้ด้วยตา
สองแถวสรุปข้างล่างบอกว่าสัดส่วนของชั้นออกแบบ ลดลง จากหนึ่งในห้าเหลือหนึ่งในเจ็ด ส่วนสัดส่วนของชั้นประสิทธิภาพ เกือบสองเท่า (14.8% → 25.0%)
แต่แถวราย version ข้างบนบอกอะไรที่คมกว่านั้น และมันคือเหตุผลที่ตารางนี้ต้องมีสิบแถว ไม่ใช่สองแถว:
สิบห้าในสิบเจ็ดตัวของชั้นออกแบบอยู่ใน C# 8 ถึง 11 เหลือแค่สองตัวสำหรับ C# 12, 13 และ 14 รวมกัน
นั่นไม่ใช่การขยับของสัดส่วน มันคือหน้าผา
ตัวเลขราย version คือ 3 · 3 · 2 · 2 · 1 · 5 · 2 ไม่ใช่เส้นที่ไต่ขึ้นอย่างสวยงาม แต่เป็นเส้นที่แกว่งอยู่แถว ๆ สองสามตัว แล้วมี C# 13 โผล่ขึ้นมาเป็นห้า
พูดอีกอย่างคือ ครึ่งหนึ่งของชั้นประสิทธิภาพใน4 version หลังมาจาก version เดียว
การพูดว่า “ชั้นประสิทธิภาพโตขึ้นเรื่อย ๆ” จึงเป็นการเอาตัวเลขระดับช่วงมาแปะเป็นแนวโน้มราย version ซึ่งข้อมูลชุดนี้ไม่รองรับ
สิ่งที่รองรับได้คือประโยคที่อ่อนกว่าและจริงกว่า — กองที่ต้องประชุมหดลงอย่างชัดเจน ส่วนกองที่รอผลวัดโตขึ้นเป็นก้อน ๆ ตาม version ที่บังเอิญมีของกลุ่มนั้นมาเยอะ
ไม่ใช่เพราะ C# หมดของจะให้ แต่เพราะ คำถามที่ชั้นออกแบบตอบได้มีจำนวนจำกัด และคำถามใหญ่ที่สุดเรื่องการสร้างแบบจำลอง object ถูกตอบไปแล้วในช่วงต้น
คำถามพวกนั้นคือ “ตัวนี้เป็น null ได้ไหม”, “เท่ากันแปลว่าอะไร” และ “กิ่งเหล่านี้คือทั้งหมดที่มีจริงไหม”
เมื่อคำถามพวกนี้มีคำตอบอยู่ในลายเซ็นแล้ว งานที่เหลือของทีมภาษาก็ย้ายไปอยู่ในเรื่องที่คำถามเรื่องการพิสูจน์ไม่ใช่คำถามที่ถูกตั้งแต่แรก คือเรื่องอายุหน่วยความจำ buffer และการคุยกับ code ที่ไม่ได้จัดการหน่วยความจำให้ ซึ่งตัดสินด้วยผลวัด
สังเกตด้วยว่าเส้นแบ่งของหน้านี้ไม่ได้ถูกออกแบบมาเพื่อเรื่องหลังเลย มันเกิดจากคำถามเรื่องการพิสูจน์ล้วน ๆ แต่มันจัดของในเรื่องนั้นได้ครบ 40 รายการ โดยไม่ต้องตั้งเกณฑ์ใหม่ขึ้นมาข้าง ๆ
ต้นทุนของการยก version ภาษาไม่ได้วัดด้วยจำนวน feature แต่วัดด้วยขนาดของชั้นออกแบบ
ชั้นออกแบบคือกองที่ต้องประชุม ต้องมีบันทึกการตัดสินใจ และต้องทยอยเปิด
ชั้นประสิทธิภาพคือกองที่คุณ มีสิทธิ์เพิกเฉย จนกว่าจะมีผลวัดมาขอ
วัดด้วยไม้บรรทัดนั้น 4 version หลังถูกกว่า3 version แรกชัดเจน — 6 ตัวที่ต้องคิดหนัก เทียบกับ 11 ตัว
และมันถูกลงทั้งที่จำนวน feature รวมไม่ได้ต่างกันมาก (40 เทียบกับ 54) นั่นคือประเด็น: จำนวนรายการในบันทึกรุ่นไม่ใช่หน่วยวัดของต้นทุน
ข่าวดีข้อนี้มีรูปแบบที่ซื่อสัตย์อยู่รูปเดียว
“ชั้นออกแบบเล็กลง” ไม่ได้แปลว่า version หลัง ๆ ปลอดภัยกว่า มันแปลว่าอันตรายย้ายที่
ของที่มีกับดักคมที่สุดในหน้านี้แทบทั้งหมดอยู่ในชั้นประสิทธิภาพ ไม่ใช่ชั้นออกแบบ:
พูดให้ตรงคือกองนี้ ถูกที่จะเพิกเฉย แพงที่จะหยิบมาใช้แบบไม่ดูอะไรเลย ซึ่งเป็นคนละอย่างกับ “ปลอดภัย”
และตัวเลขในตารางก็ไม่ได้แปลว่าการยก version ราบเรียบเท่ากันทุกขั้น ชั้นออกแบบทั้ง7 version กระจุกอยู่ที่ C# 8 (ห้าตัว) และ C# 11 (สี่ตัว) เป็นหลัก
การข้ามจาก C# 10 ไป 11 จึงแพงกว่าการข้ามจาก 12 ไป 14 ทั้งชุดรวมกัน
ตัวเลขทุกตัวในตารางนี้พิมพ์เป็นตัวหนังสืออยู่ในหน้าจริง แต่ไม่ได้ถูกปล่อยให้พิมพ์อยู่เฉย ๆ
test test/article-csharp-8-to-14.test.ts (อยู่ในชุด npm run test:unit) นับทั้งสิบแถวใหม่จาก file ข้อมูลเดียวกับที่คลังท้ายหน้าอ่าน และ script ตรวจดึงไป compile แล้วเทียบกับตัวเลขที่พิมพ์ไว้ตรงนี้ทีละตัว
ถ้ามีใครสลับป้ายชั้นของ feature สักตัว สองฝั่งจะไม่ตรงกัน และ test จะดังก่อนที่ประโยคนี้จะกลายเป็นเท็จ
หกหัวข้อถัดจากนี้เจาะเฉพาะจุดที่การอ่านคำอธิบายในคลังอย่างเดียวไม่พอ
4 feature ที่ห่างกันสูงสุดห้าปี กำลังไล่ตอบคำถามชุดเดียวกันทีละครึ่ง แยกกันอ่านทีละ version จะมองไม่เห็นเลยว่ามันเป็นชุดเดียวกัน
ถ้าจะมีเหตุผลข้อเดียวที่7 version นี้ควรอยู่ในหน้าเดียวกัน มันคือหัวข้อนี้
record ไม่ได้ถูกใส่มาเพื่อให้พิมพ์สั้นลง ถึงแม้ผลข้างเคียงจะเป็นแบบนั้นก็ตาม
สิ่งที่มันเปลี่ยนคือ 2 object ที่มีค่าเท่ากันคือสิ่งเดียวกัน และ compiler เป็นคนสังเคราะห์ Equals, GetHashCode, ==, != และ copy constructor ที่ทำให้นิพจน์ with ใช้ได้ ให้ครบทั้งชุดจากรายชื่อสมาชิกเดียวกัน
ไม่ใช่คนเขียนแล้วลืมอัปเดตตอนเพิ่ม field ที่ห้าในหกเดือนถัดมา ซึ่งเป็น bug ที่เงียบที่สุดประเภทหนึ่งเท่าที่มี
init ย้ายข้อความว่า “เขียนได้เฉพาะตอนสร้าง” เข้าไปอยู่ในลายเซ็นของ property ที่ compiler บังคับได้
เลิกต้องเลือกระหว่าง “สร้างง่ายด้วย object initializer” กับ “แก้ทีหลังไม่ได้” อย่างใดอย่างหนึ่งเหมือนที่เคยต้องเลือก
record กับ init มักถูกเปิดพร้อมกันจนคนคิดว่าเป็นของชิ้นเดียว แต่มันตอบคนละคำถามกันชัดเจน
required ย้ายประโยคว่า “ช่องนี้ขาดไม่ได้” จากคอมเมนต์และจากการโยน exception ตอนรัน มาเป็นข้อบังคับที่ compiler ปฏิเสธ ผู้เรียก
object initializer ที่ลืมช่องไปช่องหนึ่ง ได้ error CS9035 ตั้งแต่ compile
นี่เป็นรูปแบบการบังคับที่แข็งที่สุดที่มีในหน้านี้ เพราะมันมีผลกับ code ที่อยู่นอกชนิดของคุณ และได้มาโดยไม่ต้องแลกความสะดวกของ object initializer ไปเป็น constructor ที่มี parameter สิบตัว
เดิมทีการมีเงื่อนไขตรวจค่าใน setter ต้องแลกด้วย field ที่มีชื่อ — private string _currency — ซึ่งสมาชิกทุกตัวใน class เดียวกันเขียนทับได้ตรง ๆ ข้ามด่านตรวจไปเลย
field ทำให้ที่เก็บค่านั้น ไม่เหลือชื่อให้ใครอ้างถึง :
คำว่า field มีความหมายเฉพาะข้างใน accessor เท่านั้น เขียนนอก accessor ได้ error CS0103
field จริงที่ compiler สร้างชื่อว่า <Currency>k__BackingField ซึ่งพิมพ์เป็น C# ไม่ได้
ผลคือทุกการเขียนลงที่เก็บค่าจากซอร์สเดินผ่าน setter เสมอ เพราะไม่มีทางอื่นให้เดิน ไม่ใช่เพราะทีมมีวินัย
และความต่างระหว่างสองเหตุผลนั้น คือความต่างระหว่างชั้นออกแบบกับความหวัง
C# 9 ให้ความหมายของความเท่ากัน กับหน้าต่างเวลาที่เขียนได้
C# 11 ให้อำนาจบังคับผู้เรียก
C# 14 ให้ที่วางกฎที่เดินอ้อมไม่ได้
สี่ตัวนี้เป็นชั้นออกแบบทั้งหมด และเป็นสี่ในสิบเจ็ดตัวของทั้งหน้า
สี่ตัวนี้รวมกันแล้วยังไม่ได้แปลว่าคุณมี value object
หนึ่ง — record ไม่ได้แปลว่า immutable
property ที่คุณประกาศเองในตัว record ใส่ set ได้ตามปกติทุกประการ
record struct ที่ประกาศแบบ positional เปล่า ๆ ก็ แก้ค่าได้ เพราะ parameter ของมันกลายเป็น property ที่มีทั้ง get และ set เต็มตัว ถ้าอยากได้ของที่แช่แข็งจริงต้องเขียน readonly record struct เสมอ
แม้แต่ init ก็แช่แข็งแค่ ตัวอ้างอิง ไม่ใช่ของที่มันชี้ไป — List<T> ที่ประกาศด้วย init ยังถูก Add ต่อได้ตลอดชีวิตของ object
สอง — สิ่งที่ยังขาดคือ invariant และไม่มีตัวไหนในสี่ตัวนี้ให้
record Money(decimal Amount, string Currency) ยอมรับจำนวนเงินติดลบและรหัสสกุลเงินที่เป็น string ว่างได้ทั้งคู่ ตราบใดที่ไม่มีใครเขียน guard ไว้เอง
required บังคับแค่ว่า มีการกำหนดค่า ไม่ได้บอกว่าค่านั้นถูก ใส่ Total = new Money(-1m, "") ก็ผ่านฉลุย
field ให้ ที่ทาง ที่ปลอดภัยสำหรับวาง guard แต่ไม่ได้เขียน guard ให้
สิ่งที่ภาษาให้มาตลอดห้าปีนี้คือ “ที่วางกฎซึ่งไม่มีใครเดินอ้อมได้” กับ “การบังคับให้ผู้เรียกส่งค่ามา” ส่วนตัวกฎยังต้องเขียนด้วยมือเหมือนเดิมทุกประการ
การพิมพ์ record แล้วเรียกมันว่า value object จึงยังเป็นการติดป้ายชื่อ ไม่ใช่การบังคับกฎ
สาม — ทั้งคู่มีต้นทุนที่ต้องรู้ก่อนหยิบ
required เป็น breaking change สำหรับผู้เรียกที่ compile ผ่านอยู่แล้ว และ deserializer หรือ factory ที่สร้าง object แทนผู้เรียกต้องประกาศ [SetsRequiredMembers] ไม่งั้นเรียกไม่ผ่าน
field ไม่คุ้มเลยกับ property ที่ get; set; เปล่า ๆ และมีกับดักชื่อซ้อน — ถ้า class มีสมาชิกชื่อ field อยู่ก่อน คำในตัว accessor จะไปผูกกับที่เก็บค่าตัวใหม่แทน พร้อม warning CS9258 ที่บอกให้เขียน this.field หรือ @field ถ้าหมายถึงตัวเดิม
การเปลี่ยนนั้นเขียน Equals กับ GetHashCode ของชนิดนั้นใหม่ทั้งชุด ทุกจุดต่อไปนี้จะเปลี่ยนพฤติกรรมทันที โดยไม่มี error สักตัว:
ชนิดนั้นถูกใช้เป็น key ของ Dictionary
ชนิดนั้นเป็นสมาชิกของ HashSet
ชนิดนั้นถูกใช้เป็น key ของ cache
ชนิดนั้นถูกเทียบด้วย == โดยตั้งใจให้หมายถึง “object ตัวเดียวกัน”
มันคือการเปลี่ยนที่ compile ผ่านเสมอ และพังตอนรันเท่านั้น — เหตุผลที่ควรทำทีละชนิด ไม่ใช่ทั้ง repo ใน commit เดียว และเป็น bug ตัวเดียวกับที่เปิดหน้านี้
หน้านี้จงใจไม่สอน record, init, required, field และการวาง value object ทีละขั้น เพราะคอร์ส C# สมัยใหม่สำหรับงาน DDD ทำเรื่องนั้นอยู่แล้ว แบบลงมือเขียนจริงบน domain Order ตัวเดียวกับที่คุณเห็นในคลังท้ายหน้า
หน้านี้ให้แผนที่ ส่วนคอร์สนั้นให้เครื่องมือ
feature เดียวในหน้านี้ที่ควรมีบันทึกการตัดสินใจ (ADR) ของตัวเอง และเหตุผลไม่ใช่เพราะมันยาก แต่เพราะมันแพง
nullable reference types ย้ายเจตนาว่า “ตัวนี้เป็น null ได้หรือไม่ได้” จากคอมเมนต์ จากชื่อ method และจากความหวังของคนเขียน มาอยู่ในลายเซ็นที่ compiler อ่านออก
Order Find(int id) กับ Order? Find(int id) เคยเป็นสิ่งเดียวกันในสายตาเครื่องมือ ตอนนี้ไม่ใช่แล้ว นั่นคือประโยคที่ทำให้มันอยู่ชั้นออกแบบ
มันเตือน มันไม่ได้พิสูจน์ — และเส้นแบ่งนี้สำคัญมาก
การวิเคราะห์ของ nullable reference types เป็น การไล่ตามเส้นทางของค่าแล้วเตือน ไม่ใช่การพิสูจน์ทางคณิตศาสตร์ว่าโปรแกรมนี้ไม่มีทาง NullReferenceException
รูที่รู้กันมีอย่างน้อยสี่รู:
! ที่ปิดปากมันได้ทุกจุด โดยไม่ต้องแก้อะไรเลย
code จาก library หรือ project ที่ยังไม่ได้ annotate ซึ่ง compiler ไม่มีข้อมูลจะไล่
generic ที่ยังไม่รู้ว่า T เป็น reference หรือ value type
ค่าที่เข้ามาจากนอกระบบชนิดทั้งหมด เช่นการ deserialize JSON หรือ reflection ซึ่งยัด null ลง property ที่ประกาศว่าไม่เป็น null ได้สบาย ๆ เพราะตอนรันไม่มีใครตรวจ
มันย้าย NullReferenceException จำนวนมากมาเป็นคำเตือนตอน compile แต่ไม่ได้ย้ายมาทั้งหมด
ใครที่ขายมันด้วยประโยคว่า “เปิดแล้วหมดปัญหา null” กำลังขายของที่ไม่มีอยู่
ต่างจาก feature อื่นทั้งหน้าที่เลือกใช้เป็นจุด ๆ ได้ พอเปิดสวิตช์เดียว คำเตือนจะขึ้นทั้ง project ในวินาทีเดียว
และคำเตือนหลักพันมีค่าเท่ากับศูนย์เสมอ เพราะไม่มีใครอ่าน แผนที่ใช้ได้จริงจึงเป็นแผนที่ยอมให้มันช้า
เปิดทีละ project ไม่ใช่ทั้ง solution — สวิตช์นี้อยู่ใน file .csproj ไม่ใช่ที่ solution เริ่มที่ project ที่พึ่งพา project อื่นน้อยที่สุด ซึ่งมักเป็น project ที่มีแต่ type ของ domain พอมันถูก annotate แล้ว project ที่เหลือจะได้ข้อมูลชุดนั้นไปใช้ต่อทันทีตอนถึงคิวของมัน ถ้า project เดียวก็ยังใหญ่เกินไป ยังมี #nullable enable ที่เปิดทีละ file ได้อีกชั้นหนึ่ง
อยู่ที่ระดับคำเตือนให้นานพอ แล้วค่อยเลื่อนเป็น error — การตั้ง <WarningsAsErrors>Nullable</WarningsAsErrors> ตั้งแต่วันแรกฟังดูเด็ดขาดดี แต่ผลจริงคือทีมจะไล่โปรย ! เพื่อให้ build ผ่านให้ทันของที่ต้องส่ง ซึ่งแย่กว่าไม่เปิดเลย เพราะ code จะดูเหมือนผ่านการตรวจแล้วทั้งที่ไม่ เลื่อนเป็น error เมื่อ project นั้นสะอาดจริงแล้วเท่านั้น และเลื่อนทีละ project เหมือนกัน
annotate ขอบนอกก่อนขอบใน — ขอบนอกคือที่ที่ null เข้ามาในระบบจริง ๆ ได้แก่ผลลัพธ์จากฐานข้อมูล, body ที่ deserialize มาจาก HTTP, ค่าจาก config และผลของ third-party การใส่ ? ให้ถูกต้องตรงนั้นทำให้การวิเคราะห์ทุกชั้นที่อยู่ข้างในมีความหมายขึ้นมาทันที ส่วนการเริ่มจาก helper ชั้นในสุดในขณะที่ผู้เรียกทั้งหมดยังไม่ได้ annotate นั้นแทบไม่ได้อะไรกลับมาเลย เพราะ compiler ยังไม่มีข้อมูลฝั่งที่ค่าไหลเข้ามา
สามข้อนี้กินเวลาเป็นสัปดาห์ ไม่ใช่เป็นบ่าย
และนั่นคือเหตุผลที่มันควรถูกเขียนไว้เป็นการตัดสินใจที่มีวันที่กำกับ ไม่ใช่บรรทัดหนึ่งใน commit ที่ชื่อ “modernize”
ทั้งกองเป็นชั้นการพิมพ์ ยกเว้น switch แบบนิพจน์ตัวเดียว และนี่คือเส้นที่ถูกทดสอบหนักที่สุดในหน้านี้
ลากเส้นยาว ๆ จาก C# 8 ถึง C# 11 จะเห็นว่า pattern ถูกเติมทีละนิดแทบทุก version:
C# 8 — pattern ของ property ของ tuple และแบบ positional
C# 9 — ชื่อชนิดล้วน ๆ, วงเล็บ, and, or, not และการเทียบมากกว่า/น้อยกว่า
C# 10 — เขียนจุดลงไปหลายชั้นใน pattern เดียวได้
C# 11 — pattern ของ list
ทั้งกองนี้ติดป้าย เปลี่ยนการพิมพ์ ทุกตัว มีตัวเดียวในสายนี้ที่ติดป้าย เปลี่ยนการออกแบบ และมันมาตั้งแต่ C# 8 คือ switch แบบนิพจน์
สองฝั่งอยู่ใกล้กันมากจนดูเหมือนของกองเดียวกัน เหตุผลที่ลากเส้นตรงนี้มีสองข้อ และทั้งสองข้อตรวจสอบได้ด้วยการ compile ไม่ใช่ด้วยรสนิยม
ข้อแรก — switch แบบนิพจน์บังคับโครงสร้าง ทุกกิ่งต้องให้ค่าชนิดเดียวกันออกมา เส้นทางที่ลืมคืนค่าไม่มีทางหลุดผ่าน compiler ได้เลย ไม่ใช่ว่าจะได้คำเตือน แต่คือ compile ไม่ผ่าน
ข้อสอง — มันทำให้ประโยคว่า “กิ่งเหล่านี้คือทั้งหมดที่มี” กลายเป็นข้อความที่ตรวจได้ ถ้าไม่ใส่กิ่ง _ ไว้ compiler จะเตือน CS8509 พร้อมบอกค่าที่ยังไม่ได้จัดการ ทันทีที่มีใครเพิ่มค่าใหม่เข้าไปใน enum ในอีกหกเดือน
ของที่ใกล้เคียงที่สุดในฝั่ง switch แบบ statement คือ CS0161 ซึ่งเกิดเฉพาะเมื่อ method ต้องคืนค่า และไม่ได้บอกว่าค่าไหนหายไป ส่วน method ที่คืน void นั้นเงียบสนิท
pattern แต่ละแบบเขียนใหม่เป็น &&, ||, ! หรือ is แบบเดิมได้ทุกตัว โดยที่ ความจริงที่ code บอกไม่ขยับเลยสักนิด
line.Quantity is >= 1 and <= 5 กับ line.Quantity >= 1 && line.Quantity <= 5 เป็นข้อความเดียวกันเป๊ะ ๆ
ต่างกันแค่ว่าอันแรกเอ่ยชื่อสิ่งที่ทดสอบครั้งเดียว และอ่านเป็น “ช่วง” ได้ตรง ๆ ซึ่งเป็นข้อดีจริง และเป็นข้อดีของ การอ่าน ล้วน ๆ
“ก็ switch แบบนิพจน์เขียนใหม่เป็น switch แบบ statement ได้เหมือนกันนี่” ซึ่งจริง — แต่เขียนแล้วจะไม่ได้ CS8509 กลับมา และนั่นคือความต่างทั้งหมด
สิ่งที่หายไปไม่ใช่ความสวยของ code แต่คือคำเตือนที่จะดังในวันที่มีคนเพิ่มค่าใหม่ใน enum
ด้วยเหตุผลเดียวกันนี้ กิ่ง _ => throw ... ที่หลายคนใส่ไว้ “กันเหนียว” จึงเป็นสิ่งที่ต้องคิดก่อนใส่ เพราะมันปิดคำเตือนนั้นทิ้งไปด้วย แล้วสิ่งที่เหลือคือระเบิดตอนรันแทน
รายละเอียดทีละแบบว่าอันไหนอ่านดี อันไหนพัง — เช่น not ที่ผูกแน่นกว่า or จนกิ่งหลังตายทิ้งโดยไม่มีใครเตือน — อยู่ในคลังท้ายหน้าทีละตัวแล้ว
และนั่นไม่ใช่ความล้มเหลว รุ่นที่ไม่เพิ่มการพิสูจน์อะไรเลย คือรุ่นที่ยก version ได้ถูกที่สุด
7 feature 0 ชั้นออกแบบ 6 ชั้นพิมพ์สั้นลง 1 ชั้นประสิทธิภาพ — เลขศูนย์ตัวนั้นอยู่ในตารางข้างบนให้เห็นกับตาแล้ว
ฟังดูเหมือนคำวิจารณ์ แต่ Microsoft เขียนเหตุผลไว้เองในหน้าเดียวกับที่หน้านี้นับบุลเล็ตมา:
“Overall, C# 12 provides new features that make you more productive writing C# code. Syntax you already knew is available in more places.”
ไวยากรณ์ที่คุณรู้จักอยู่แล้ว ใช้ได้ในที่ที่มากขึ้น — นี่คือคำอธิบายของรุ่นที่เป็นชั้นการพิมพ์ทั้งรุ่น ในคำของเจ้าของภาษาเอง
[Experimental] แพ้ด้วยการวัด ไม่ใช่ด้วยรสนิยม
API ที่ติดแอตทริบิวต์นี้แล้วถูกเรียกใช้ให้ hard error ก็จริง แต่ [Obsolete(..., error: true)] ให้ error ที่มีรูปร่างเหมือนกันเป๊ะที่จุดเรียกเดียวกัน มาตั้งแต่รุ่นแรกของภาษา
สิ่งที่ใหม่จริงมีข้อเดียวคือรหัสของ diagnostic ตั้งเองได้ การปิดเสียงจึงทำได้ทีละ API แทนที่จะเป็นแบบเหมารวม
นั่นคือ ความละเอียดที่เพิ่มขึ้นของการบังคับที่มีอยู่แล้ว ไม่ใช่ข้อเท็จจริงใหม่ที่ compiler พิสูจน์ได้ — เขียนประโยค “ตอนนี้ compiler รับประกันว่า X” ไม่ออก เพราะ X ตัวนั้นถูกรับประกันมาก่อนแล้ว
primary constructor เป็นตัวที่คนเรียกว่าชั้นออกแบบบ่อยที่สุด ซึ่งก็ไม่ใช่
parameter ของมันคือตัวแปรที่ถูกจับไว้ใช้ต่อและ แก้ค่าได้ ธรรมดา ไม่ใช่ field readonly ที่ compiler ค้ำให้
ส่วน CS8862 ที่มันพามาด้วย (constructor ตัวอื่นใน class ต้องเรียก this ต่อ) เป็นผลที่ตามมาทางตรรกะจากการที่คุณประกาศรายการ parameter ไว้ที่หัว class เอง ไม่ใช่ข้อเท็จจริงเกี่ยวกับ code ของคนอื่น
ต่างจาก CS9035 ของ required ที่ไปบังคับผู้เรียก ข้อควรระวังจริงของมันจึงเป็นเรื่องงาน — การเติม primary constructor ให้ class ที่มีอยู่แล้ว แปลว่าต้องไล่แก้ constructor ตัวอื่นทุกตัวใน class นั้น
รุ่นที่ไม่เพิ่มการพิสูจน์อะไรเลย คือรุ่นที่ยก version ได้ถูกที่สุด นั่นคือความหมายที่แท้จริงของแถวที่เป็นศูนย์
ข้อควรระวังข้อเดียวคือ ในกองนี้มีแค่ของที่เป็นกฎจัดรูปแบบล้วน ๆ เท่านั้นที่กวาดทั้ง repo ได้ใน commit เดียว
ส่วน collection expression กับ primary constructor เป็นของที่หยิบมาใช้ตอนเขียน code ใหม่ ไม่ใช่สวิตช์ให้เปิด
กองที่โตขึ้นใน4 version หลังไม่ได้โตแบบกระจัดกระจาย มันเป็นเส้นเดียวที่ต่อกันมา4 version
เรียง feature ของ4 version หลังตามความสามารถแทนที่จะเรียงตาม version จะเห็นเส้นนี้:
ref fields ทำให้ ref struct เก็บอ้างอิงไปยังที่เก็บของคนอื่นได้ โดยมี compiler ตรวจอายุให้ ซึ่งเป็นสิทธิ์ที่เดิม Span<T> ได้รับเป็นกรณีพิเศษจาก runtime
ref struct ประกาศ implement interface ได้ แต่ แปลง ตัวเองไปเป็นชนิด interface ยังไม่ได้ เพราะการแปลงนั้นคือการ box — ตัวมันเองจึงแทบไม่มีประโยชน์ถ้าอยู่ลำพัง
allows ref struct คือทางเดียวที่จะเรียกสมาชิกของสัญญานั้นได้จริงผ่าน generic
การแปลง Span โดยปริยายชุดใหม่ ลบการคัดลอกตรงรอยต่อทิ้ง
ขนานกันไปคือ inline array กับ params ที่รับ collection ได้ ซึ่งเลิกพึ่งอาร์เรย์ก้อนแยกบน heap
สังเกตว่าเส้นนี้ ข้ามชั้น สองตัวในนั้นคือ ref fields กับ allows ref struct เป็นชั้นออกแบบ เพราะมันบังคับอะไรบางอย่างกับคุณจริง ๆ ส่วนที่เหลือเป็นชั้นประสิทธิภาพ
การจัดชั้นตัดสินทีละ feature ตามคำถามข้อเดียว ไม่ได้ตัดสินตามว่า feature นั้นอยู่ในเรื่องเล่าเดียวกับใคร นี่คือจุดที่คนอ่านเส้นเรื่องแล้วเผลอเหมาบ่อยที่สุด
ดูที่แถว C# 13 ในตารางข้างบนก็เห็นชัด version นั้นมีชั้นออกแบบอยู่ ตัวเดียว และตัวนั้นคือ allows ref struct ซึ่งอยู่ในเส้นเรื่องเดียวกับอีกห้าตัวที่นับอยู่ในช่องประสิทธิภาพของแถวเดียวกัน
allows ref struct คือการที่ method สละสิทธิ์ของตัวเอง ติดไว้ทุกที่เพราะดูเหมือนของฟรี แล้วคนที่เจ็บคือคนที่มาเขียนต่อ
params ReadOnlySpan<T> ทำให้จุดเรียกเหมือนเดิมทุกตัวอักษร และทำลาย binary compatibility ของ library ที่ปล่อยไปแล้วพร้อมกัน สองข้อนี้อยู่ด้วยกันได้ เพราะจุดเรียก “เหมือนเดิม” หมายถึงในซอร์ส ไม่ใช่ในแอสเซมบลีที่ compile ไปแล้ว
operator += ที่เขียนเอง แก้ค่าในที่เดิม (วัดแล้ว: ตัวแปรที่ชี้ไปที่เดียวกันเห็นค่าที่เปลี่ยน) การเติมมันให้ชนิดที่ปล่อยไปแล้ว จึงเปลี่ยนความหมายของ += ทุกจุดที่มีอยู่เดิมแบบเงียบ ๆ
lock บน System.Threading.Lock ถ้าเผลอเก็บตัวล็อกไว้ในตัวแปรชนิด object มันจะตกกลับไปใช้ Monitor แบบเดิม ได้พฤติกรรมถูกต้องแต่ไม่ได้สิ่งที่ตั้งใจมาเอา (compiler เตือนด้วย CS9216 ซึ่งเป็นคำเตือนที่ต้องอ่าน ไม่ใช่ปิด)
กติกาของกองนี้เหมือนกันทั้ง7 version ต่างแค่มันใหญ่ขึ้นในช่วงหลัง:
รอจนมีผลวัดชี้มาที่มันก่อน แล้วหยิบทีละตัว ทีละเส้นทางเรียก แล้ววัดซ้ำ
เพราะประโยชน์ของกองนี้เป็นเรื่องเฉพาะจุด ส่วนต้นทุนของมันเป็นเรื่องทั้งระบบ
[SkipLocalsInit] ของ C# 9 กับ function pointer ใน version เดียวกัน ก็อยู่ในกองนี้มาตั้งแต่ต้นด้วยเหตุผลเดียวกันเป๊ะ — ทั้งคู่แลกความปลอดภัยบางอย่างทิ้งไปเพื่อความเร็ว
feature ที่ถูกเสนอให้เป็นชั้นออกแบบบ่อยที่สุดในหน้านี้ และการวัดตอบว่าไม่ใช่
extension แบบใหม่ ของ C# 14 มีเหตุผลที่ฟังขึ้นมาก — มันไม่ได้เพิ่มแค่ method อีกต่อไป property อินเด็กเซอร์ และสมาชิก static ก็แขวนเข้ากับชนิดที่เราไม่ได้เป็นเจ้าของได้แล้ว
ฟังดูเหมือนชนิดนั้น กลายเป็น อะไรบางอย่างที่มันไม่เคยเป็น
การวัดตอบว่าไม่ ประกาศ IsFinished เป็น extension member ให้ชนิดหนึ่ง แล้วส่งชนิดนั้นเข้า method generic ที่มี where T : IFinishable ผลคือ error CS0311 เหมือนเดิมทุกประการ
extension member ไม่นับว่า implement interface และไม่ผ่าน generic constraint
สิ่งที่มันเป็นคือการเรียก static ที่เขียนให้อ่านเหมือนสมาชิก ซึ่งเป็นข้อดีของการอ่านล้วน ๆ
และเป็นบรรทัดฐานเดียวกับที่ใช้กับ GetEnumerator แบบ extension ของ C# 9 ที่อยู่ในคลังเดียวกันนี้ ตัวนั้นทำให้ foreach วิ่งบนชนิดที่ไม่มี GetEnumerator ได้ ซึ่งฟังดูใหญ่กว่านี้อีก และก็เป็นชั้นการพิมพ์ด้วยเหตุผลเดียวกัน
ห้าปีห่างกัน เกณฑ์เดียวกัน คำตอบเดียวกัน
กับดักในการวัดข้อนี้ ที่หลอกคนตรวจไปแล้วหนึ่งรอบ
รอบแรกของการวัดข้อนี้ให้ผลว่า “ผ่าน” ด้วยการทดสอบว่า (IFinishable)o compile ได้ไหม
ซึ่ง compile ได้เสมอสำหรับ class ที่ไม่ได้ sealed ทุกตัว เพราะ compiler ยอมให้ cast ลงไปหาชนิดลูกที่อาจมีอยู่ การทดสอบนั้นจึงไม่ได้พิสูจน์อะไรเลย
ต้องเปลี่ยนมาใช้ชนิดที่ sealed บวก generic constraint ถึงจะได้คำตอบจริงคือ CS0311
เล่าไว้ตรงนี้เพราะใครที่อยากตรวจข้อนี้ด้วยตัวเองจะเดินเข้ากับดักอันเดียวกัน และเพราะการจัดชั้นทั้งหน้านี้ยืนอยู่บนการวัดแบบนี้ ไม่ใช่บนความรู้สึกว่าอะไรฟังดูใหญ่
<LangVersion> ไม่ใช่สวิตช์เดียวที่ตัดสินว่า “เราอยู่ที่ C# version ไหน” และเรื่องนี้มาจากการ compile จริง ไม่ใช่จากการอ่านเอกสาร
ใน feature ทั้งหมดของหน้านี้ มีอยู่ 9 ตัวจาก 94 ที่ไม่ได้ถูกคุมด้วย <LangVersion> เลย กระจายอยู่4 version:
ทั้งเก้าตัว compile ผ่านที่ LangVersion ก่อนหน้าของตัวเองได้สบาย ๆ ทั้งที่โทเค็นของ feature ยังอยู่ครบในซอร์ส
ด้านแรก — สองตัวถูกยืนยันถึงขั้นที่มันทำงานจริง ไม่ใช่แค่ถูกยอมให้ผ่าน
[CallerArgumentExpression] ถูกสร้างเป็น exe ที่ LangVersion 9 แล้วรัน มันพิมพ์ข้อความของ argument ที่ผู้เรียกพิมพ์ไว้ออกมาได้ เหมือนที่ C# 10 ทำทุกประการ
#line แบบระบุช่วง คำเตือนที่ compiler รายงานถูกแม็ปไปยัง column ตามช่วงที่ระบุจริง ต่างจากตำแหน่งที่ได้เมื่อใช้รูปแบบเดิม
ด้านที่สอง — ตัว compiler เอง Roslyn กัน feature ตาม version ด้วยข้อความแม่แบบว่า Feature '…' is not available in C# …
แปลว่าทุก feature ที่ถูกกันไว้ ต้องมี ชื่อ ของตัวเองเป็น string อยู่ใน file Microsoft.CodeAnalysis.CSharp.dll
feature อื่นของ C# 10 มีชื่อครบทุกตัว แต่ CallerArgumentExpression กับ #line แบบระบุช่วงไม่มีชื่ออยู่ใน file นั้นเลย และ feature ที่ไม่มีชื่อ ไม่มีทางถูกกันไว้ด้วยกลไกนี้ได้
ข้อแรก — การตรึง <LangVersion> ไว้ไม่ได้แช่แข็ง build ของคุณ การอัปเกรด SDK อย่างเดียวโดยไม่แตะซอร์สสักบรรทัด ก็ทำให้ code ที่เคยติด CS0165 compile ผ่านได้ และทำให้การวิเคราะห์ null แม่นขึ้นทั้งสองทาง — ซึ่งแปลว่ามีคำเตือนใหม่โผล่ได้ด้วย และมันเป็นคำเตือนที่ถูก ไม่ใช่ของเสีย
ข้อสอง — เวลาไล่ปัญหาว่า “ทำไมเครื่องเขา compile ผ่านแต่เครื่องเราไม่ผ่าน” ให้ถามสองคำถามเสมอ ไม่ใช่คำถามเดียว: <LangVersion> เท่าไร และ SDK รุ่นไหน
หัวใจไม่ได้อยู่ที่ว่าอันไหนมาก่อน แต่อยู่ที่ว่าสองกองบนต้องไม่อยู่ใน commit เดียวกัน
flowchart TB
S["Upgrading an existing codebase from C# 8 towards C# 14"]
S --> T["Typing layer first - 59 of 94"]
T --> T1["ONE repo-wide commit, mechanical rules only: file-scoped namespaces, global usings, target-typed new"]
T1 --> T2["Reviewable from the diff stat - anything the compiler proves does NOT belong in here"]
T2 --> D["Design layer next - 17 of 94, never in that same commit"]
D --> N["Nullable reference types: one project at a time, warnings first, outer boundaries before inner ones"]
D --> R["Records, init, required, field: one type at a time"]
R --> R1["For each converted type, re-check every dictionary key, set member and cache key"]
D --> A["Every design-layer feature gets its own PR and its own written decision"]
S --> P["Perf / interop layer - 18 of 94: not on the upgrade path at all"]
P --> P1["Adopt only when a measurement points at it, one call path at a time, then measure again"]
P1 --> P2["allows ref struct gives away your own rights; params ReadOnlySpan breaks binary compatibility"]
ภาพ 2 — ลำดับเดียวกันนี้ทำให้ commit “modernize” และ PR “ตาม version ภาษาให้ทัน” ที่ขึ้นต้นหน้านี้กลายเป็นของที่รีวิวได้ · หัวใจไม่ได้อยู่ที่ว่าอันไหนมาก่อน แต่อยู่ที่ว่าสองกองบนต้องไม่อยู่ใน commit เดียวกัน เพราะคนรีวิวใช้เกณฑ์คนละอันกับมัน ส่วนกองที่สามไม่ได้อยู่บนเส้นทางอัปเกรดเลย มันรอผลวัดอยู่คนละที่
เหตุผลที่มันปลอดภัยไม่ใช่เพราะมันเล็ก แต่เพราะ มันไม่เปลี่ยนอะไรที่ compiler พิสูจน์ให้เลย
สองตัวที่เป็นการจัดรูปแบบล้วน ๆ คือ namespace แบบบรรทัดเดียวกับ new() เป็นกฎใน .editorconfig ที่ dotnet format ไล่แก้ให้ได้ทั้ง repo ด้วยคำสั่งเดียว ส่วน global using ต้องเขียนเอง1 file แล้วไล่ลบ using ที่ซ้ำออก
commit แบบนี้จึงรีวิวได้จาก diff stat — ทุกบรรทัดที่เปลี่ยนต้องอธิบายได้ด้วยกฎที่ตั้งไว้ หรือด้วย file using file เดียวนั้น อะไรที่อธิบายด้วยสองอย่างนี้ไม่ได้คือของแปลกปลอมที่ต้องแยกออกไป
ข้อควรระวังของสามตัวนี้เป็นเรื่องการอ่าน ไม่ใช่ความถูกต้อง:
global using ที่ยัดทุกเนมสเปซเข้าไป ทำให้ file ที่เหลือไม่มีอะไรบอกว่าชื่อที่โผล่มานั้นมาจากไหน
target-typed new อย่าใช้สลับไปมากับ var ใน file เดียวกัน เพราะอันหนึ่งบอกชนิดไว้ทางขวา อีกอันบอกไว้ทางซ้าย
ด่านแรก — ประเด็นของ feature นี้คือต้นทุนตอนรัน หรือการคุยกับ code ที่จัดการหน่วยความจำเองใช่ไหม
ถ้าใช่ มันคือชั้นประสิทธิภาพ/interop ซึ่งไม่ได้อยู่ในแผนอัปเกรดเลย ให้รอจนมีผลวัดมาขอ ถึงค่อยหยิบมาทีละตัว
ด่านที่สอง — ถ้าไม่ใช่ ให้ถามว่าเขียนประโยคออกไหม ว่ามันทำให้ compiler พิสูจน์หรือบังคับอะไรเพิ่ม
เขียนไม่ออก = ชั้นการพิมพ์ ไม่ต้องเสียเวลาประชุมเรื่องมันอีก
เขียนออก = ชั้นออกแบบ และเป็นของที่คุ้มค่ากับการประชุม
ทั้งหน้าไม่มี block code C# ของตัวเองเลยสัก block code ทั้งหมดอยู่ในคลังท้ายหน้า ซึ่งอ่านจาก file เดียวกับที่ script ตรวจดึงไป compile
ไม่มีสำเนาชุดที่สองให้เพี้ยนจากกัน สิ่งที่ script นั้น (npm run test:csharp) ตรวจมีสี่ทิศ และตัวเลขล่าสุดที่รันคือ 94/94 · 85/85 · 9/9 · 87/87 :
ช่อง “code ใหม่” ทั้ง 94 ช่อง compile ผ่านที่ LangVersion ของ version ตัวเองโดยไม่มี warning สักตัว
ช่องเดียวกันนั้น ถูกปฏิเสธ ที่ LangVersion ก่อนหน้าด้วยรหัส error ที่ถูกต้อง 85 ตัว ซึ่งเป็นทิศที่ทำให้ป้าย version แปลว่า “ต้องใช้” ไม่ใช่แค่ “ใช้ด้วยกันได้”
อีก 9 ตัวคือ feature ในหัวข้อที่แล้วที่ไม่ผูกกับ <LangVersion> เลย มันไม่มีทิศปฏิเสธให้ตรวจตามนิยามของตัวมันเอง script จึงยืนยันตรงข้ามแทน คือยืนยันว่ามัน ผ่านจริง ที่ version ก่อนหน้า แล้วเหลือแค่การเช็คโทเค็นในซอร์ส ซึ่งอ่อนกว่าอย่างชัดเจน และเป็นเก้าตัวที่ควรถูกอ่านด้วยตาคน
ช่อง “code เดิม” ที่มีของให้เทียบ 87 ช่อง compile ผ่านที่ LangVersion ก่อนหน้าได้จริง (อีกเจ็ดช่องว่างเพราะเขียนแบบเดิมไม่ได้เลย)
นอกจากเก้าตัวนั้นแล้ว ยังมีอีกสี่จุดที่คำโฆษณาของคลังครอบไปไม่ถึง และทั้งสี่จุดควรได้รับการอ่านด้วยตาคน
หน้า version history ของ Microsoft ไม่ได้ใส่มันไว้เป็นบุลเล็ต แต่เป็นประโยคต่อท้ายรายการว่ามันออกมาในสถานะ Preview feature ซึ่งอยู่นอกเกณฑ์ที่หน้านี้ใช้เลือกของอยู่แล้ว
และการวัดยืนยันว่าถ้าฝืนใส่เข้าไป คำโฆษณาของคลังจะเป็นเท็จทันทีสำหรับตัวมัน:
interceptor ที่ใช้งานได้จริงถูก compile ที่ -langversion:11.0 สำเร็จ แล้วเอา exe ที่ได้ไปรัน มันพิมพ์ผลของ code ที่ถูกแทรกออกมาจริง — feature ที่ควรอยู่ใต้ป้าย C# 12 ทำงานครบถ้วนภายใต้ version ภาษาก่อนหน้า
รูปแบบที่เขียนด้วยมือได้ยัง เตือน เสมอ (CS9270 ที่บอกให้ย้ายไปใช้รูปแบบที่ generator สร้างให้) ซึ่งตกกฎ “ห้ามมีคำเตือนสักตัว” ของ script ตรวจ
การเปิดใช้ต้องส่งธง -features:InterceptorsNamespaces=<ns> (property <InterceptorsNamespaces> ใน MSBuild) — ชื่อเดิม InterceptorsPreviewNamespaces ที่ยังเจอในบทความเก่าทั่วอินเทอร์เน็ต ไม่ถูกยอมรับแล้ว บน SDK 10.0.302
สรุปคือมันเป็นเครื่องมือของคนเขียน source generator ไม่ใช่ไวยากรณ์ที่จะหยิบมาพิมพ์เอง
การที่สองชื่อนี้กลายเป็นชื่อเรียกของ IntPtr/UIntPtr ผูกอยู่กับ RuntimeFeature.NumericIntPtr ของ target framework ไม่ใช่กับ <LangVersion>
บน net10.0 มันจึงเป็นจริงอยู่แล้ว ไม่ว่าจะตั้ง version ภาษาไว้เท่าไร ไม่มี version ไหนที่ snippet ของมันถูกปฏิเสธ และไม่มีพฤติกรรมตอนรันให้ชี้ด้วย
หลักฐานทั้งหมดที่หน้านี้มีสำหรับรายการนี้คือ การตรวจว่าโทเค็นยังอยู่ในซอร์ส ซึ่งอ่อนกว่าทุกตัวใน file
นี่ไม่ใช่สวิตช์ที่กดได้ และหน้านี้ไม่ยอมให้ป้าย version พูดแทนมัน
ที่ LangVersion 13.0 คำว่า extension ยังไม่ใช่ไวยากรณ์ ตัวแยกไวยากรณ์จึงพังลงเป็น CS1513 + CS1022 ซึ่งแปลว่า “ต้องมีปีกกาปิด”
ไม่มีคำใดในสองข้อความนั้นโยงกลับไปหาชื่อ feature หรือเลข version เลย พิมพ์ปีกกาผิดที่ไหนก็ได้ข้อความชุดเดียวกัน แถวนี้จึงพิสูจน์ได้แค่ว่า “ถูกปฏิเสธ” ไม่ใช่ “ถูกปฏิเสธเพราะ version”
อีกตัวที่อ่อนพอ ๆ กันคือกลุ่ม method ที่หาชนิดธรรมชาติเจอ ซึ่งได้ CS8917 ที่ไม่มีข้อความเรื่อง version เช่นกัน
สิ่งที่ค้ำมันไว้จริงคือ negative control ที่วัดไว้ต่างหาก คือลบโอเวอร์โหลด generic ทิ้งแล้วซอร์สเดิมเป๊ะ compile ผ่านที่ LangVersion 12.0 ได้ — การปฏิเสธจึงมาจากกฎที่เปลี่ยนจริง ไม่ใช่จากความพังอื่นของ snippet
มันอยู่ในคลังเพราะ Microsoft ระบุมันไว้เป็นบุลเล็ต แต่ช่อง “code เดิม” ของมันว่าง และช่อง “code ใหม่” ก็ไม่ใช่ไวยากรณ์ใหม่
เป็น code ธรรมดาที่ compile ผ่านทั้งก่อนและหลังเหมือนกันทุกประการ ต่างกันแค่ compiler พูดอะไรเพิ่มขึ้นมาหนึ่งประโยค (CS8981 เรื่องชื่อชนิดที่เป็นตัวพิมพ์เล็ก ASCII ล้วน)
และมันดังเหมือนกันเป๊ะทั้งที่ LangVersion 10.0 และ 11.0 เพราะมันผูกกับ ระดับคำเตือน (<AnalysisLevel> / -warn:) ไม่ใช่กับ version ภาษา
ป้าย “เปลี่ยนการพิมพ์” ที่มันได้ เป็นป้ายที่พอดีที่สุดในสามป้ายที่มี ไม่ใช่ป้ายที่เหมาะ
ผลจริงกับทีมมีข้อเดียว และเป็นข้อที่ควรรู้ก่อน — ทีมที่เปิด TreatWarningsAsErrors จะได้ build แดงทันทีที่ยกระดับการวิเคราะห์ โดยไม่ได้แตะซอร์สสักบรรทัด และไม่ได้แตะ version ภาษาเลย
ทั้งหมดนี้ compile ด้วย .NET SDK 10.0.302 ที่ตั้ง LangVersion ย้อนกลับไป ไม่ใช่ด้วย SDK ของยุคนั้นจริง ๆ
สิ่งที่ครอบคลุมจึงเป็น ไวยากรณ์และ error ของภาษา ไม่ใช่พฤติกรรมตอนรันของ .NET Core 3.0 ถึง .NET 9 อย่างที่มันเป็นในตอนนั้น
ถ้าคุณกำลังตัดสินใจเรื่องที่ขึ้นกับพฤติกรรมของ runtime รุ่นนั้น ๆ ให้วัดบน runtime รุ่นนั้น อย่าอ้างหน้านี้
คำถามข้อเดียวที่หน้านี้ใช้ เกิดมาจากเรื่องการสร้างแบบจำลอง object — null, ความเท่ากัน, ความครบถ้วนของกิ่ง ซึ่งเป็นเรื่องของ C# 8 ถึง 10 เต็ม ๆ
ช่วง4 version หลังลงไปใกล้เครื่องกว่านั้นมาก มันเต็มไปด้วย ref, Span, scoped, buffer และ interop ซึ่งไม่ใช่เรื่องที่คำถาม “compiler พิสูจน์อะไร” เกิดมาเพื่อตอบ และเป็นช่วงที่สัญชาตญาณ “ไวยากรณ์ใหม่ = ความปลอดภัยใหม่” ผิดหนักกว่าเดิม
แต่ 94 รายการเข้าที่ครบ โดยไม่ต้องตั้งเกณฑ์ใหม่ขึ้นมาข้าง ๆ สักข้อ และตัวที่ฟังดูเหมือนชั้นออกแบบที่สุดสองตัวในช่วงหลัง — [Experimental] กับ extension members — ถูกปฏิเสธด้วยผลจาก compiler ไม่ใช่ด้วยรสนิยมของใคร
ประโยชน์ที่แท้จริงของเส้นนี้ไม่ใช่การได้ป้ายชั้นมาแขวน แต่คือรายการยาว 94 บรรทัดที่กลายเป็นสามรายการที่มีกติการีวิวคนละแบบ:
กองที่ใช้ได้เลยเมื่อถึงคราว
กองที่รอผลวัด
กองเล็ก ๆ สิบเจ็ดตัวที่คุ้มกับการประชุม
ทั้ง commit “modernize” และ PR “ตาม version ภาษาให้ทัน” ที่ขึ้นต้นหน้านี้ จะรีวิวได้ทันทีที่ของสามชนิดนั้นไม่อยู่ใน commit เดียวกัน
ต่างกันแค่ commit แรกไม่มีเกณฑ์ให้ใช้ ส่วน commit ที่สองมีแล้วไม่ได้ใช้
อ่านต่อ
ข้างล่างนี้คือ feature ครบทุกตัวที่ Microsoft ระบุชื่อไว้เป็นบุลเล็ตใน7 version ไม่ได้คัดมาเฉพาะตัวที่น่าสนใจ
ทุกตัวแสดงอยู่ตั้งแต่เปิดหน้า เรียงตาม version — ค้นด้วย Ctrl-F ได้ทั้งหน้า ส่วนแถบเลือก version มีไว้สำหรับตอนที่อยากอ่านเฉพาะ version เดียวรวดเดียว และกด “ทั้งหมด” เพื่อเอาทุกตัวกลับมาได้ตลอด
ป้ายใต้ชื่อ feature คือคำตอบของคำถามที่หน้านี้ใช้ทั้งหมด:
เปลี่ยนการออกแบบ จะมีบรรทัด “compiler รับประกันว่า:” ตามมาเสมอ ซึ่งเป็นประโยคที่ระบุว่าความจริงข้อใหม่คืออะไร
อีกสองป้ายไม่มีบรรทัดนั้น เพราะเขียนไม่ออก
วิธีอ่านที่ได้ผลที่สุดคือไล่ดูทีละ version แล้วนับเองว่า version นั้นมีบรรทัด “compiler รับประกันว่า:” กี่บรรทัด — สัดส่วนที่เห็นคือเหตุผลทั้งหมดว่าทำไมแผนอัปเกรดถึงไม่ควรเป็น commit เดียว
ช่อง code ใหม่ ทุกช่องถูก compile ด้วย .NET SDK จริงที่ LangVersion ของ version นั้นโดยไม่มี
warning สักตัว แล้วตรวจซ้ำว่ามันถูก ปฏิเสธ ที่ LangVersion ก่อนหน้าด้วยรหัส error ที่ถูกต้อง
ส่วนช่อง code เดิม ที่มีของให้เทียบ ถูกตรวจว่า compile ผ่านที่ LangVersion ก่อนหน้าได้จริง —
ยกเว้น 9 feature ที่ไม่ได้ผูกกับ LangVersion เลย ซึ่งไม่มีทิศปฏิเสธให้ตรวจ จึงใช้ทิศผ่านบวกการเช็คโทเค็นในซอร์สแทน
สร้างซ้ำได้ด้วย npm run test:csharp
version ทั้งหมด
C# 8
C# 9
C# 10
C# 11
C# 12
C# 13
C# 14
C# 8 — 17 feature
Nullable reference types เปลี่ยนการออกแบบ อ้างอิงที่ประกาศได้ว่าเป็น null ได้หรือไม่ได้ # link ถาวรของ Nullable reference types
compiler รับประกันว่า: เจตนาว่า "ตัวนี้เป็น null ได้หรือไม่ได้" ย้ายจากคอมเมนต์และความหวังของคนเขียน มาอยู่ในลายเซ็นที่ compiler อ่านออก แล้วมันจะเตือนทุกเส้นทางที่มันมองเห็น — เตือน ไม่ใช่พิสูจน์ เพราะ !, code ที่ยังไม่ annotate, generic และ reflection ยังลอดไปได้
เปิดแล้ว compiler จะถือว่า reference type ทุกตัว ไม่ เป็น null เว้นแต่เขียน ? ต่อท้าย แล้วไล่เตือนทุกจุดที่อาจ dereference ค่าที่เป็น null ได้
code เดิม
private readonly List <Order > _orders = new List <Order >();
public Order Find (int id )
{
// ไม่มีอะไรในลายเซ็นบอกว่าอาจได้ null กลับไป
return _orders.FirstOrDefault (o => o.Id == id);
}
code ใหม่
private readonly List <Order > _orders = new List <Order >();
public Order ? Find (int id )
{
// '?' คือส่วนหนึ่งของสัญญา ผู้เรียกที่ลืมเช็คจะโดนเตือนตั้งแต่ compile
return _orders.FirstOrDefault (o => o.Id == id);
} ใช้ตอนไหน เปิดกับ codebase ที่ NullReferenceException โผล่บ่อยในโปรดักชัน · ไม่ควร เปิดทั้ง solution พร้อมกันใน commit เดียว ให้เปิดทีละ project และเริ่มที่ระดับ warning ก่อน ไม่งั้นจะได้ warning หลักพันที่ไม่มีใครอ่าน
Switch expressions เปลี่ยนการออกแบบ switch ที่เป็นนิพจน์ ให้ค่าออกมาแทนที่จะสั่งงาน # link ถาวรของ Switch expressions
compiler รับประกันว่า: ทุกกิ่งต้องให้ค่าชนิดเดียวกันออกมา — เส้นทางที่ลืมคืนค่าไม่มีทางหลุดผ่าน compiler และถ้าไม่ใส่กิ่ง _ ไว้ compiler จะเตือน (CS8509) ทันทีที่มีค่าใหม่โผล่เข้ามาใน enum
รูปแบบ x switch { pattern => value, ... } ที่ให้ ค่า กลับมาหนึ่งค่า แทน switch แบบเดิมที่เป็น statement ไม่มี case/break และไม่มีกิ่งไหนที่ไม่ให้ค่า
code เดิม
public static string Describe (OrderStatus status )
{
switch (status)
{
case OrderStatus .Placed : return "รอร้านตอบรับ" ;
case OrderStatus .Delivered : return "ส่งถึงลูกค้าแล้ว" ;
default : throw new ArgumentOutOfRangeException (nameof (status));
}
}
code ใหม่
public static string Describe (OrderStatus status ) => status switch
{
OrderStatus .Placed => "รอร้านตอบรับ" ,
OrderStatus .Delivered => "ส่งถึงลูกค้าแล้ว" ,
_ => throw new ArgumentOutOfRangeException (nameof (status)),
}; ใช้ตอนไหน ใช้กับการแปลงค่าเข้าเป็นค่าออกที่ไม่มีผลข้างเคียง เช่น สถานะ → ข้อความ หรือสถานะ → นโยบาย · ระวัง กิ่ง _ => ที่ใส่ไว้กันเหนียว มันปิดคำเตือน CS8509 ทิ้งไปด้วย พอเพิ่มค่าใหม่ใน enum จะไม่มีใครฟ้อง เหลือแค่ระเบิดตอนรัน · ถ้ากิ่งไหนต้องทำงานหลาย statement ให้กลับไปใช้ switch แบบเดิม
Default interface members เปลี่ยนการออกแบบ สมาชิกของ interface ที่พก implementation มาด้วย # link ถาวรของ Default interface members
compiler รับประกันว่า: interface พูดได้แล้วว่า 'ถ้าไม่เขียนเอง จะได้พฤติกรรมนี้' — การเพิ่มสมาชิกใหม่จึงไม่ใช่ breaking change ที่ compiler ต้องฟ้องทุก class ที่ implement อยู่
interface ประกาศ method พร้อม body ได้ class ที่ implement อยู่แล้วและไม่รู้จักสมาชิกตัวใหม่จะได้ body นั้นไปใช้โดยอัตโนมัติ
code เดิม
public interface INotifier
{
void Send (string message );
// เพิ่ม method ใหม่ใน interface = ทุก class ที่ implement อยู่ compile ไม่ผ่านทันที
void SendUrgent (string message );
}
code ใหม่
public interface INotifier
{
void Send (string message );
// มี body ติดมาด้วย class เดิมที่ไม่รู้จัก method นี้ก็ยัง compile ผ่าน
void SendUrgent (string message ) => Send ("[ด่วน] " + message);
} ใช้ตอนไหน ใช้ตอนต้องขยาย interface ที่มีคนอื่นนอกทีม implement อยู่ เช่น interface ของ library ที่ปล่อยไปแล้ว · อย่า ใช้มันแทน class ฐาน (base class) เพราะ interface ยังไม่มี state และการเรียกผ่านตัวแปรชนิด class จะมองไม่เห็น default implementation ที่เขียนไว้
Readonly members เปลี่ยนการออกแบบ สมาชิกของ struct ที่ประกาศว่าไม่แตะ state # link ถาวรของ Readonly members
compiler รับประกันว่า: method ตัวนี้ไม่เปลี่ยนสถานะของ struct — compiler ตรวจในตัว method เลย และเพราะรู้แน่แล้วจึงเลิกแอบทำสำเนาป้องกัน (defensive copy) ตอนเรียกผ่านตัวแปร readonly หรือ parameter in
ติด readonly ให้ method หรือ property ของ struct ทีละตัวได้ ไม่ต้องทำทั้ง struct เป็น readonly struct ทั้งก้อน
code เดิม
public struct Point
{
public double X { get ; set ; }
public double Y { get ; set ; }
// ไม่มีอะไรบอกว่า method นี้ไม่แตะ state — คนอ่านต้องเชื่อเอาเอง
public double Length () => Math.Sqrt (X * X + Y * Y);
}
code ใหม่
public struct Point
{
public double X { get ; set ; }
public double Y { get ; set ; }
// readonly = สัญญาว่าไม่แตะ state ถ้าเผลอเขียน X = 0 จะ compile ไม่ผ่าน
public readonly double Length () => Math.Sqrt (X * X + Y * Y);
} ใช้ตอนไหน ใช้กับ struct ที่ยังแก้ค่าได้บางส่วนแต่มี method อ่านอย่างเดียวปนอยู่ · ไม่ควร ไล่ติด readonly ทีละตัวถ้าทั้ง struct ไม่เคยเปลี่ยนค่าเลย — กรณีนั้นประกาศ readonly struct ทีเดียวจบและสื่อสารได้ตรงกว่า · กับ class ใช้ไม่ได้เลย ไม่ใช่ใส่แล้วเฉย ๆ — ติดให้ method ของ class เมื่อไรได้ error CS0106: The modifier 'readonly' is not valid for this item ทันที
Asynchronous streams เปลี่ยนการออกแบบ ลำดับข้อมูลที่ไหลมาทีละชิ้นแบบไม่ block # link ถาวรของ Asynchronous streams
compiler รับประกันว่า: ลายเซ็นบอกความจริงสองข้อพร้อมกัน — ผลลัพธ์มาทีละชิ้น และแต่ละชิ้นต้องรอ — สองข้อที่ Task<List<T>> กับ IEnumerable<T> ต่างพูดได้แค่ข้อเดียว
IAsyncEnumerable<T> คู่กับ await foreach ทำให้ method หนึ่งตัว yield return ค่าออกมาทีละชิ้นโดยที่ระหว่างชิ้นยัง await ได้
code เดิม
public static async Task <List <int >> ReadAllAsync ()
{
var all = new List <int >();
for (int page = 0 ; page < 3 ; page++ )
{
await Task.Delay (10 );
all.Add (page); // ต้องรอให้ครบทุกหน้าก่อน ผู้เรียกถึงจะได้เห็นอะไรสักอย่าง
}
return all;
}
code ใหม่
public static async IAsyncEnumerable <int > ReadPagesAsync ()
{
for (int page = 0 ; page < 3 ; page++ )
{
await Task.Delay (10 );
yield return page; // ส่งทีละหน้า ผู้เรียกเริ่มทำงานได้ก่อนหน้าถัดไปมาถึง
}
} ใช้ตอนไหน ใช้กับของที่มาเป็นชุดและไม่รู้ว่าจบเมื่อไร เช่น ผลลัพธ์แบบแบ่งหน้าจาก API หรือแถวจากฐานข้อมูล · ระวัง สองเรื่อง หนึ่ง ถ้าอยากให้ token ที่ผู้เรียกส่งผ่าน await foreach (... .WithCancellation(token)) ไหลเข้ามาถึงตัว method ต้องติด [EnumeratorCancellation] ให้ parameter CancellationToken ด้วย (รับ token เป็น parameter ธรรมดาแล้วเช็คเองก็ยังยกเลิกได้ แต่ของที่มาจาก WithCancellation จะไม่ถึงมือ) สอง อย่า ใช้กับข้อมูลก้อนเล็กที่โหลดทีเดียวจบ เพราะได้ state machine เพิ่มมาโดยไม่ได้อะไรตอบแทน
Property patterns เปลี่ยนการพิมพ์ จับคู่ด้วยค่าของ property # link ถาวรของ Property patterns
เขียน x is { Prop: value } เพื่อตรวจว่าไม่เป็น null และตรวจค่าของ property ในนิพจน์เดียว ซ้อนเข้าไปหลายชั้นได้ · รูปแบบเปล่า { } ไม่ได้ตรวจชนิดให้ ถ้าต้องการตรวจชนิดด้วยให้เขียนชนิดนำหน้า x is Order { ... }
code เดิม
public static bool IsEmptyDraft (Order order )
{
if (order == null ) return false ;
return order.Status == OrderStatus.Placed && order.Lines.Count == 0 ;
}
code ใหม่
// อ่านเป็นรูปร่างที่ต้องการ ไม่ใช่ลำดับการเช็คทีละเงื่อนไข
public static bool IsEmptyDraft (Order order ) =>
order is { Status: OrderStatus .Placed , Lines: { Count: 0 } }; ใช้ตอนไหน ใช้ตอนเงื่อนไขคือ 'ออบเจ็กต์หน้าตาแบบนี้ไหม' และมีการเช็ค null ปนอยู่ด้วย เพราะ pattern เช็คให้ในตัว · ระวัง การซ้อนเกินสองชั้น มันอ่านยากกว่า if ธรรมดาทันที · ใน C# 8 ค่าที่เทียบได้ต้องเป็นค่าคงที่เท่านั้น เขียน > 1000 ไม่ได้ ต้องรอ relational pattern ของ C# 9
Tuple patterns เปลี่ยนการพิมพ์ จับคู่หลายค่าพร้อมกันเป็น tuple # link ถาวรของ Tuple patterns
มัดค่าหลายตัวเป็น tuple แล้วจับคู่ทั้งชุดในครั้งเดียว (a, b) switch { (true, true) => ... } แทนการไล่ if ซ้อนกันทีละเงื่อนไข
code เดิม
public static string Surge (bool isRaining , bool isRushHour )
{
if (isRaining && isRushHour) return "x2.0" ;
if (isRaining) return "x1.5" ;
if (isRushHour) return "x1.3" ;
return "x1.0" ;
}
code ใหม่
public static string Surge (bool isRaining , bool isRushHour ) => (isRaining, isRushHour) switch
{
(true , true ) => "x2.0" ,
(true , false ) => "x1.5" ,
(false , true ) => "x1.3" ,
(false , false ) => "x1.0" ,
}; ใช้ตอนไหน เหมาะกับตารางตัดสินใจเล็ก ๆ ที่ input สองสามตัวและค่าที่เป็นไปได้จำกัด เพราะเขียนครบทุกช่องแล้วเห็นรูตารางเลย · ไม่ควร ใช้เมื่อ input เกินสามตัว จำนวนกิ่งโตแบบคูณจนกลายเป็นกำแพงข้อความ ตอนนั้นควรแยกเป็นตารางข้อมูลหรือ policy object แทน
Positional patterns เปลี่ยนการพิมพ์ จับคู่ด้วยตำแหน่งผ่าน Deconstruct # link ถาวรของ Positional patterns
ถ้า type มี method Deconstruct จะจับคู่ด้วยตำแหน่งได้เลย x is (_, 0) โดยไม่ต้องเอ่ยชื่อ property
code เดิม
public readonly struct Slot
{
public Slot (int hour , int minute ) { Hour = hour; Minute = minute; }
public int Hour { get ; }
public int Minute { get ; }
}
public static bool IsOnTheHour (Slot slot ) => slot.Minute == 0 ;
code ใหม่
public readonly struct Slot
{
public Slot (int hour , int minute ) { Hour = hour; Minute = minute; }
public int Hour { get ; }
public int Minute { get ; }
public void Deconstruct (out int hour , out int minute ) => (hour, minute) = (Hour, Minute);
}
// จับคู่ด้วยตำแหน่งผ่าน Deconstruct ไม่ต้องเอ่ยชื่อ property
public static bool IsOnTheHour (Slot slot ) => slot is (_ , 0 ); ใช้ตอนไหน คุ้มกับ type ที่ลำดับของส่วนประกอบเป็นที่รู้กันอยู่แล้ว เช่น พิกัด ช่วงเวลา หรือ record ที่มี positional parameter · อย่า ใช้กับ type ที่มีสมาชิกชนิดเดียวกันหลายตัว (เช่น (int, int, int)) เพราะสลับตำแหน่งผิดแล้ว compiler ไม่ฟ้อง — กรณีนั้น property pattern ที่มีชื่อกำกับปลอดภัยกว่า
Using declarations เปลี่ยนการพิมพ์ ประกาศ using โดยไม่ต้องเปิด block # link ถาวรของ Using declarations
เขียน using var x = ...; เป็น statement เดียว แล้ว compiler เรียก Dispose() ให้ตอนหลุดสโคป แทนการห่อด้วย block using (...) { }
code เดิม
public static void Save (string path , string text )
{
using (var writer = new StreamWriter (path))
{
writer.WriteLine (text);
}
}
code ใหม่
public static void Save (string path , string text )
{
using var writer = new StreamWriter (path);
writer.WriteLine (text);
// Dispose ถูกเรียกตอนออกจากสโคป ไม่ต้องมี block ซ้อนอีกชั้น
} ใช้ตอนไหน ใช้เมื่อทรัพยากรมีอายุเท่ากับทั้ง method พอดี ซึ่งเป็นกรณีส่วนใหญ่ และช่วยตัดการเยื้องซ้อนกันสามสี่ชั้นออก · ระวัง เมื่อต้องการให้ปล่อยทรัพยากรเร็วกว่าจบ method เช่น เปิด file แล้วต้องปิดก่อนไปเรียกโพรเซสอื่นมาอ่าน file เดิม — กรณีนั้น block using (...) ที่กำหนดขอบเขตชัดยังจำเป็น
Static local functions เปลี่ยนการพิมพ์ function ใน method ที่ห้ามจับตัวแปรข้างนอก # link ถาวรของ Static local functions
ติด static ให้ local function เพื่อห้ามมันอ้างถึงตัวแปรหรือ this จากสโคปที่ครอบอยู่ input ทุกตัวต้องผ่าน parameter
code เดิม
public static int Total (int [] amounts )
{
int vat = 7 ;
// จับ vat จากข้างนอกมาใช้ได้เงียบ ๆ แก้ vat ทีหลังแล้วผลลัพธ์เปลี่ยนโดยไม่มีใครรู้
int WithVat (int amount ) => amount + (amount * vat / 100 );
int sum = 0 ;
foreach (int a in amounts) sum += WithVat (a);
return sum;
}
code ใหม่
public static int Total (int [] amounts )
{
int sum = 0 ;
foreach (int a in amounts) sum += WithVat (a, 7 );
return sum;
// static = ห้ามจับตัวแปรข้างนอก input ทุกตัวต้องผ่าน parameter
static int WithVat (int amount , int vat ) => amount + (amount * vat / 100 );
} ใช้ตอนไหน ติด static เป็นค่าเริ่มต้นให้ local function ทุกตัวที่ไม่ได้ตั้งใจจะจับตัวแปรข้างนอก แล้ว compiler จะฟ้องทันทีที่มีคนเผลอไปอ้างของนอกสโคป · ระวัง ข้ออ้างเรื่องความเร็ว การจับตัวแปรของ local function ธรรมดาไม่ได้ alloc closure เสมอไป กำไรที่แน่กว่าคือความชัดของ input ไม่ใช่ตัวเลข benchmark
Shadowing parameters in lambdas and local functions เปลี่ยนการพิมพ์ parameter ของ function ซ้อนใช้ชื่อซ้ำกับตัวข้างนอกได้ # link ถาวรของ Shadowing parameters in lambdas and local functions
Microsoft ระบุไว้ว่า "lambda expressions, anonymous methods, and local functions can declare parameters that shadow local variables and parameters from an enclosing scope" — ชื่อที่เคยชนกันจนได้ CS0136 ตอนนี้ประกาศซ้ำได้
code เดิม
public static string Shout (string message )
{
// ตั้งชื่อ parameter ของ lambda ว่า message ไม่ได้ ชนกับตัวข้างนอก (CS0136)
Func <string , string > upper = m => m.ToUpperInvariant ();
return upper (message);
}
code ใหม่
public static string Shout (string message )
{
// C# 8 ยอมให้ parameter ของ lambda ใช้ชื่อซ้ำกับตัวข้างนอกได้
Func <string , string > upper = message => message.ToUpperInvariant ();
return upper (message);
} ใช้ตอนไหน ได้ประโยชน์จริงตอนเขียน LINQ ต่อกันยาว ๆ ที่ทุกขั้นอยากเรียกตัวแปรว่า order เหมือนกัน จะได้ไม่ต้องคิดชื่อ o2 o3 · ระวัง ให้หนักกว่าเดิม เพราะกฎเดิมที่เคยกันความสับสนหายไปแล้ว ชื่อซ้ำในสโคปซ้อนกันคืออุบัติเหตุที่รอเกิด — ใช้เมื่อสองตัวหมายถึงของสิ่งเดียวกันเท่านั้น
Indices and ranges เปลี่ยนการพิมพ์ ตัวดำเนินการนับจากท้ายและตัดช่วง # link ถาวรของ Indices and ranges
^n คือดัชนีที่นับจากท้าย และ a..b คือช่วง ใช้กับอาร์เรย์ string และ Span<T> ได้ผ่าน type ใหม่ System.Index กับ System.Range
code เดิม
public static string Summarize (string [] items )
{
string last = items[items.Length - 1 ];
var tail = new string [2 ];
Array.Copy (items, items.Length - 2 , tail, 0 , 2 );
return last + " / " + string .Join ("," , tail);
}
code ใหม่
public static string Summarize (string [] items )
{
string last = items[^ 1 ]; // ^1 = ตัวสุดท้าย นับจากท้าย
string [] tail = items[^ 2 .. ]; // ^2.. = ช่วงสองตัวท้าย
return last + " / " + string .Join ("," , tail);
} ใช้ตอนไหน ใช้ตัดหัวตัดท้ายของอาร์เรย์และ string โดยไม่ต้องคำนวณ Length - 1 เอง ซึ่งเป็นแหล่ง off-by-one คลาสสิก · ระวัง การตัดช่วงบนอาร์เรย์ คัดลอก ข้อมูลออกมาเป็นก้อนใหม่เสมอ ถ้าอยู่ใน loop ร้อนให้ใช้ AsSpan()[^2..] ที่ไม่ alloc แทน · ไวยากรณ์นี้ต้องมี type System.Index/System.Range รองรับ จึงใช้กับ .NET Framework ไม่ได้ทันที ต้องประกาศ type พวกนั้นเอง (polyfill) และถ้าจะตัดช่วงบนอาร์เรย์ต้องมี RuntimeHelpers.GetSubArray ด้วย
Null-coalescing assignment เปลี่ยนการพิมพ์ กำหนดค่าให้เฉพาะตอนที่ยังเป็น null # link ถาวรของ Null-coalescing assignment
x ??= y เขียนค่า y ลง x ก็ต่อเมื่อ x เป็น null อยู่ ถ้าไม่ใช่ก็ไม่ประเมิน y เลย
code เดิม
public static List <string > Ensure (List <string > items )
{
if (items == null )
{
items = new List <string >();
}
return items;
}
code ใหม่
public static List <string > Ensure (List <string > items )
{
items ??= new List <string >(); // เขียนค่าให้เฉพาะตอนที่ยังเป็น null เท่านั้น
return items;
} ใช้ตอนไหน เหมาะกับ lazy initialization ของ backing field และการเติมค่าเริ่มต้นให้ parameter ที่รับมาเป็น null ได้ · ระวัง เรื่อง thread safety — ??= ไม่ได้เป็น atomic ถ้าหลาย thread เรียกพร้อมกันจะสร้างออบเจ็กต์ซ้ำได้ ต้องใช้ Lazy<T> แทน · และ อย่า ใช้มันกลบ null ที่จริง ๆ แล้วเป็น bug ต้นทาง
Enhancement of interpolated verbatim strings เปลี่ยนการพิมพ์ สลับลำดับ $ กับ @ ของ string ได้แล้ว # link ถาวรของ Enhancement of interpolated verbatim strings
เดิม string ที่ทั้ง interpolate และ verbatim ต้องเขียน $@"..." ตามลำดับนี้เท่านั้น C# 8 รับ @$"..." ด้วย ความหมายเหมือนกันทุกประการ
code เดิม
// ก่อน C# 8 ลำดับต้องเป็น $ แล้วค่อย @ เท่านั้น สลับแล้ว compile ไม่ผ่าน
public static string LogPath (string folder ) => $@"C:\logs\{ folder}\today.txt" ;
code ใหม่
// C# 8 รับทั้ง $@ และ @$ ไม่ต้องจำลำดับอีกต่อไป
public static string LogPath (string folder ) => @$"C:\logs\{ folder}\today.txt" ; ใช้ตอนไหน ประโยชน์เดียวคือเลิกจำลำดับ ทีมที่ตั้งกฎ formatter ไว้แล้วจะได้ไม่ต้องเถียงกัน · อย่า เสียเวลาไล่แก้ของเดิมทั้ง project ให้เป็นรูปแบบใหม่ มันไม่เปลี่ยนอะไรเลยนอกจาก diff · นี่คือตัวอย่างที่ชัดที่สุดของ feature ที่เปลี่ยนแค่การพิมพ์
Disposable ref structs ประสิทธิภาพ/interop ref struct ที่ใช้กับ using ได้ # link ถาวรของ Disposable ref structs
ref struct implement IDisposable ไม่ได้เพราะแปลงเป็น interface ไม่ได้ C# 8 จึงให้ using มองหา method Dispose() ตามรูปแบบ (pattern) แทนการดู interface
code เดิม
public ref struct RentedBuffer
{
private readonly char [] _rented ;
public RentedBuffer (int size ) => _rented = ArrayPool<char >.Shared.Rent (size);
public Span <char > Span => _rented.AsSpan ();
public void Dispose () => ArrayPool<char >.Shared.Return (_rented);
}
public static void Fill (char c )
{
var b = new RentedBuffer (256 );
try { b.Span.Fill (c); } finally { b.Dispose (); } // ต้องเขียน finally เองทุกที่
}
code ใหม่
public ref struct RentedBuffer
{
private readonly char [] _rented ;
public RentedBuffer (int size ) => _rented = ArrayPool<char >.Shared.Rent (size);
public Span <char > Span => _rented.AsSpan ();
// ref struct ทำ IDisposable ไม่ได้ แต่ C# 8 รับ Dispose() ตามรูปแบบ (pattern)
public void Dispose () => ArrayPool<char >.Shared.Return (_rented);
}
public static void Fill (char c )
{
using (var b = new RentedBuffer (256 )) { b.Span.Fill (c); }
} ใช้ตอนไหน ใช้กับตัวห่อทรัพยากรบนสแต็กที่ต้องคืนของแน่ ๆ เช่น buffer ที่ยืมจาก ArrayPool ในเส้นทางร้อน · ไม่ควร เอา ref struct ไปใช้เป็น type ทั่วไปเพียงเพราะอยากได้ using แบบนี้ เพราะมันขึ้น heap ไม่ได้ ใส่ใน field ของ class ไม่ได้ และในรุ่นนี้ยังวางไว้ข้าม await ใน method async หรือข้าม yield ใน iterator ไม่ได้ด้วย
Unmanaged constructed types ประสิทธิภาพ/interop generic struct ที่ประกอบจากชนิด unmanaged ก็นับเป็น unmanaged # link ถาวรของ Unmanaged constructed types
ก่อน C# 8 type ที่ประกอบขึ้นจาก generic เช่น Pair<int> ไม่ถือว่าเป็น unmanaged ถึงจะไม่มี reference อยู่ข้างในเลยก็ตาม C# 8 ให้ถือว่าเป็น unmanaged จึงใช้กับ pointer, sizeof และ stackalloc ได้
code เดิม
// ต้องประกาศ struct เฉพาะชนิดเอง เพราะ Pair<int> ยังไม่นับเป็น unmanaged
public struct IntPair
{
public int First ;
public int Second ;
}
public static unsafe int Probe ()
{
IntPair * p = stackalloc IntPair [1 ];
p[0 ].First = 1 ;
return sizeof (IntPair );
}
code ใหม่
public struct Pair <T > where T : unmanaged
{
public T First ;
public T Second ;
}
public static unsafe int Probe ()
{
// C# 8: Pair<int> ที่ประกอบขึ้นจาก unmanaged ล้วน ก็นับเป็น unmanaged ด้วย
Pair <int >* p = stackalloc Pair <int >[1 ];
p[0 ].First = 1 ;
return sizeof (Pair <int >);
} ใช้ตอนไหน ใช้ตอนเขียน interop หรือโครงสร้างข้อมูลแบบ generic ที่ต้องวางบนสแต็กหรือส่งข้ามขอบเขต native ได้โดยไม่ต้องคัดลอก code เดิมซ้ำทุกชนิด · อย่า แตะ feature นี้ใน code ธุรกิจทั่วไป มันมาพร้อม unsafe และ pointer ซึ่งย้ายภาระการตรวจความถูกต้องจาก compiler มาที่คนเขียนทั้งหมด
Stackalloc in nested expressions ประสิทธิภาพ/interop วาง stackalloc ไว้ในนิพจน์ซ้อนได้ # link ถาวรของ Stackalloc in nested expressions
เดิมผลของ stackalloc ต้องรับด้วยตัวแปรก่อนเสมอ C# 8 ให้วางมันเป็น argument หรือส่วนหนึ่งของนิพจน์ใหญ่ได้เลย
code เดิม
public static int FindFirstEven ()
{
Span <int > numbers = stackalloc [] { 1 , 2 , 3 , 4 , 5 , 6 };
Span <int > evens = stackalloc [] { 2 , 4 , 6 }; // ต้องพักไว้ในตัวแปรก่อนเสมอ
return numbers.IndexOfAny (evens);
}
code ใหม่
public static int FindFirstEven ()
{
Span <int > numbers = stackalloc [] { 1 , 2 , 3 , 4 , 5 , 6 };
// C# 8: วาง stackalloc เป็น argument ได้เลย ไม่ต้องพักไว้ในตัวแปรก่อน
return numbers.IndexOfAny (stackalloc [] { 2 , 4 , 6 });
} ใช้ตอนไหน ใช้ตอนต้องส่ง buffer ชั่วคราวเล็ก ๆ เข้า method ที่รับ Span<T> โดยไม่อยากตั้งชื่อตัวแปรทิ้งไว้ · ระวัง ให้มาก อย่าวาง stackalloc ไว้ใน loop เพราะหน่วยความจำบนสแต็กคืนตอนจบ method ไม่ใช่ตอนจบรอบ loop วนมากพอก็ StackOverflow · ขนาดที่มาจาก input ผู้ใช้ต้องตรึงเพดานไว้เสมอ
C# 9 — 21 feature
Records เปลี่ยนการออกแบบ type อ้างอิงที่เปรียบเทียบด้วยค่า # link ถาวรของ Records
compiler รับประกันว่า: 2 instance ที่มีค่าเท่ากัน คือสิ่งเดียวกัน — compiler เขียน Equals ให้ ไม่ใช่คนเขียนแล้วลืมอัปเดตตอนเพิ่ม field
record สังเคราะห์ Equals, GetHashCode, ToString, ตัวดำเนินการ ==/!= และ copy constructor ที่ทำให้นิพจน์ with ใช้ได้ ให้อัตโนมัติทั้งหมด โดยเทียบเท่ากันจาก ค่าของสมาชิก ไม่ใช่จากตัวตนของ object · ส่วน Deconstruct ได้มาเฉพาะ record ที่ประกาศแบบ positional เท่านั้น ไม่ใช่ทุกตัว
code เดิม
public sealed class Money
{
public decimal Amount { get ; }
public string Currency { get ; }
public Money (decimal amount , string code ) => (Amount, Currency) = (amount, code);
// เขียน Equals กับ GetHashCode เองแล้ว แต่ == ยังเทียบ 'ตัวตน' อยู่เหมือนเดิม
// ต้อง overload == และ != เพิ่มอีกคู่ ไม่งั้นสองวิธีเทียบนี้ตอบไม่ตรงกัน
public override bool Equals (object ? obj ) =>
obj is Money m && m.Amount == Amount && m.Currency == Currency;
public override int GetHashCode () => HashCode.Combine (Amount, Currency);
}
code ใหม่
public sealed record Money (decimal Amount , string Currency ); ใช้ตอนไหน ใช้กับ value object และ DTO ที่ 'เท่ากัน' หมายถึงค่าเท่ากัน · ระวัง สองข้อ: record ไม่ได้แปลว่า immutable (property ที่ประกาศเองใส่ set ได้ตามปกติ) และไม่ได้แปลว่าเป็น value object ของ DDD เพราะมันไม่บังคับ invariant ให้ — ต้องเขียน guard เอง
Init only setters เปลี่ยนการออกแบบ property ที่เขียนได้เฉพาะตอนสร้าง # link ถาวรของ Init only setters
compiler รับประกันว่า: "เขียนได้เฉพาะตอนสร้าง" ย้ายมาอยู่ในลายเซ็นของ property ที่ compiler บังคับได้ — เลิกต้องเลือกระหว่าง 'สร้างง่ายด้วย object initializer' กับ 'แก้ทีหลังไม่ได้' อย่างใดอย่างหนึ่ง
ตัวเข้าถึง init แทนที่ set ทำให้ property รับค่าได้จาก constructor และจาก object initializer แต่ปฏิเสธการเขียนทับทุกครั้งหลังจาก object สร้างเสร็จ
code เดิม
public sealed class OrderDraft
{
// ต้องเปิด set ค้างไว้ object initializer ถึงจะใช้ได้ แลกกับที่ใครก็เขียนทับได้ตลอด
public int Id { get ; set ; }
public OrderStatus Status { get ; set ; }
}
public static OrderDraft Draft () =>
new OrderDraft { Id = 1 , Status = OrderStatus.Placed };
code ใหม่
public sealed class OrderDraft
{
// init = รับค่าตอนสร้างได้เหมือนเดิม แต่เขียนทับทีหลัง compile ไม่ผ่าน
public int Id { get ; init ; }
public OrderStatus Status { get ; init ; }
}
public static OrderDraft Draft () =>
new OrderDraft { Id = 1 , Status = OrderStatus.Placed }; ใช้ตอนไหน ใช้กับ DTO และ configuration object ที่อยากสร้างด้วย object initializer แล้วแช่แข็งทันที · ระวัง init แช่แข็งแค่ ตัวอ้างอิง ไม่ใช่ของที่มันชี้ไป — List<T> ที่ประกาศ init ยังถูก Add ต่อได้ตลอด ต้องเก็บเป็น IReadOnlyList<T> และคัดลอกตอนรับเข้ามาถึงจะปิดจริง
Covariant return types เปลี่ยนการออกแบบ override คืนชนิดที่เฉพาะเจาะจงกว่าเดิมได้ # link ถาวรของ Covariant return types
compiler รับประกันว่า: การ override บอกชนิดที่มันคืนจริง ไม่ใช่ชนิดที่ class ฐานรู้จัก — ผู้เรียกที่ถือชนิดลูกอยู่ได้ชนิดลูกกลับไปโดย compiler รับรอง ไม่ต้อง cast แล้วภาวนาว่าเดาถูก
method ที่ override ประกาศชนิดคืนค่าเป็นชนิดลูกของชนิดที่ class ฐานประกาศไว้ได้ ตราบใดที่แปลงเป็นชนิดของฐานได้โดยปริยาย
code เดิม
public abstract class Document
{
public abstract Document Clone ();
}
public sealed class Invoice : Document
{
// ลายเซ็นพูดได้แค่ Document ผู้เรียกที่รู้ว่าเป็น Invoice ต้อง cast กลับเอง
public override Document Clone () => new Invoice ();
}
code ใหม่
public abstract class Document
{
public abstract Document Clone ();
}
public sealed class Invoice : Document
{
// คืน Invoice ตรง ๆ ได้ ผู้เรียกที่ถือ Invoice อยู่ไม่ต้อง cast อีกต่อไป
public override Invoice Clone () => new Invoice ();
} ใช้ตอนไหน คุ้มที่สุดกับ method ตระกูล Clone/Build/WithX ที่ทุก class ลูกคืนของชนิดตัวเอง · ระวัง สองเรื่อง: มันใช้ได้กับ override เท่านั้น ไม่ใช่กับการ implement interface และการทำให้ชนิดคืนแคบลงใน class ที่ปล่อยเป็น library ไปแล้วเป็น breaking change ระดับไบนารี ถึง code ผู้เรียกจะ compile ผ่านเหมือนเดิมก็ต้อง compile ใหม่
Top-level statements เปลี่ยนการพิมพ์ เขียน statement ไว้ระดับบนสุดของ file ได้เลย # link ถาวรของ Top-level statements
file เดียวใน project เขียน statement ไว้นอก class ได้ compiler สังเคราะห์ class และ method Main ให้เอง พร้อมตัวแปร args ให้ใช้ตามเดิม
code เดิม
using System ;
namespace Kaen .Tools
{
class Program
{
static void Main (string [] args )
{
Console.WriteLine ($"รายงานยอดขาย ({ args. Length} parameter)" );
}
}
}
code ใหม่
using System ;
// ทั้ง file เหลือเท่านี้ — compiler สังเคราะห์ class กับ Main ให้ และยังมี args ให้ใช้
Console.WriteLine ($"รายงานยอดขาย ({ args. Length} parameter)" ); ใช้ตอนไหน เหมาะกับ script ตัวอย่างในเอกสาร และ file ตั้งค่าเริ่มต้นของเว็บ app ที่ code จริงอยู่ที่อื่นหมดแล้ว · ไม่ควร ปล่อยให้ file นี้โตเป็นตรรกะธุรกิจ เพราะทุกอย่างในนั้นอยู่ใน method เดียวที่ทดสอบตรง ๆ ไม่ได้ · มีได้ file เดียวต่อ project เท่านั้น
Type patterns เปลี่ยนการพิมพ์ ใช้ชื่อชนิดเปล่า ๆ เป็น pattern ได้ # link ถาวรของ Type patterns
เขียนชื่อชนิดล้วน ๆ เป็น pattern ได้โดยไม่ต้องต่อท้ายด้วยตัวแปรหรือ _ — ใช้ตอนที่สนใจแค่ 'เป็นชนิดนี้ไหม' ไม่ได้จะเอาค่าไปใช้
code เดิม
public abstract class Shape { }
public sealed class Circle : Shape { }
public sealed class Square : Shape { }
public static string Name (Shape shape ) => shape switch
{
Circle _ => "วงกลม" , // ต้องมี _ ต่อท้ายชนิดเสมอ ทั้งที่ไม่ได้ใช้ค่า
Square _ => "สี่เหลี่ยม" ,
_ => "ไม่ทราบ" ,
};
code ใหม่
public abstract class Shape { }
public sealed class Circle : Shape { }
public sealed class Square : Shape { }
public static string Name (Shape shape ) => shape switch
{
Circle => "วงกลม" , // ชื่อชนิดล้วน ๆ เป็น pattern ได้แล้ว
Square => "สี่เหลี่ยม" ,
_ => "ไม่ทราบ" ,
}; ใช้ตอนไหน ใช้ในกิ่งของ switch ที่แยกตามชนิดอย่างเดียวและไม่ต้องแตะค่าเลย · ระวัง ว่าชื่อชนิดล้วนกับชื่อค่าคงที่หน้าตาเหมือนกันเป๊ะ อยู่คนละกิ่งของ switch เดียวกันแล้วคนอ่านแยกไม่ออกว่ากิ่งไหนเทียบชนิด กิ่งไหนเทียบค่า · ไม่ควร ปล่อยให้ switch ที่แยกตามชนิดโตขึ้นเรื่อย ๆ เพราะทุกครั้งที่มีชนิดใหม่ต้องกลับมาแก้ที่นี่ ซึ่งเป็นงานที่ virtual method ทำให้ฟรีอยู่แล้ว
Parenthesized patterns เปลี่ยนการพิมพ์ ใส่วงเล็บให้ pattern เพื่อบังคับลำดับ # link ถาวรของ Parenthesized patterns
ครอบ pattern ด้วยวงเล็บเพื่อกำหนดลำดับการรวมได้ตรง ๆ · ลำดับที่ compiler ใช้เมื่อไม่มีวงเล็บคือ not ผูกแน่นที่สุด ตามด้วย and แล้วจึง or วงเล็บจึงมีผลจริงเฉพาะตอนที่ต้องขยายขอบเขตของ or ให้ and หรือ not ครอบทั้งกลุ่ม
code เดิม
public static bool IsBookable (int slot ) =>
// ต้องใส่วงเล็บเหมือนกัน แต่กฎลำดับของ && กับ || เป็นของที่ทุกคนจำได้อยู่แล้ว
(slot == 1 || slot == 2 || slot == 3 || slot == 9 ) && slot != 3 ;
code ใหม่
public static bool IsBookable (int slot ) =>
// วงเล็บทำให้ 'and not 3' ครอบทั้งกลุ่ม or — ถอดวงเล็บออกเมื่อไร
// มันจะไปผูกกับ 9 ตัวเดียว แล้วช่อง 3 กลับมาจองได้เงียบ ๆ
slot is (1 or 2 or 3 or 9) and not 3 ; ใช้ตอนไหน ใช้ทุกครั้งที่ pattern หนึ่งตัวเอา or ไปปนกับ and หรือ not เพราะนั่นคือจุดเดียวที่วงเล็บเปลี่ยนความหมายได้จริง · อย่า ใส่วงเล็บให้ pattern ที่มีตัวดำเนินการชนิดเดียวล้วนอย่าง (11 or 12) or (18 or 19) เพราะมันให้ผลเหมือนกับที่ไม่ใส่ทุกประการ ได้แค่ของประดับที่ทำให้อ่านช้าลง · ถ้าเขียนแล้วต้องนั่งนับวงเล็บ นั่นคือสัญญาณว่าควรแยกเป็น method ที่มีชื่อ
Conjunctive and patterns เปลี่ยนการพิมพ์ ต่อสอง pattern ด้วย and # link ถาวรของ Conjunctive and patterns
and รวมสอง pattern เข้าด้วยกัน ค่าจะเข้ากิ่งก็ต่อเมื่อตรงทั้งคู่ — ใช้บ่อยที่สุดกับการเขียนช่วงตัวเลขให้เป็นนิพจน์เดียว
code เดิม
public static string Bucket (OrderLine line )
{
int q = line.Quantity;
if (q >= 1 && q <= 5 ) return "ปกติ" ;
if (q >= 6 && q <= 20 ) return "สั่งเยอะ" ;
return "ต้องยืนยันกับร้านก่อน" ;
}
code ใหม่
public static string Bucket (OrderLine line ) => line.Quantity switch
{
// เอ่ยชื่อค่าที่ทดสอบครั้งเดียว แล้วต่อสอง pattern ด้วย and
>= 1 and <= 5 => "ปกติ" ,
>= 6 and <= 20 => "สั่งเยอะ" ,
_ => "ต้องยืนยันกับร้านก่อน" ,
}; ใช้ตอนไหน ใช้เขียนช่วงที่มีขอบสองด้าน เพราะเขียนแล้วอ่านเป็นช่วงจริง ๆ ไม่ใช่เงื่อนไขสองอันที่บังเอิญอยู่ด้วยกัน · เนื้อความที่ code บอกเท่ากับ && ทุกประการ ของแถมอย่างเดียวคือช่วงที่เป็นไปไม่ได้อย่าง > 20 and < 5 จะโดน compiler ฟ้อง โดยรหัสต่างกันตามรูปที่เขียน: error CS8510 เมื่ออยู่ในกิ่งของ switch expression แบบ snippet นี้ และ error CS8518 เมื่อเขียนเป็นนิพจน์ is เดี่ยว ๆ ส่วน && ไม่มีใครฟ้อง · ระวัง อย่าอ่านของแถมนี้ว่า and ยกระดับไปเป็นเครื่องมือออกแบบ ตัววิเคราะห์ความเป็นไปไม่ได้ของ pattern มีมาก่อน and อยู่แล้ว (pattern ที่ซ้อนทับกันได้ CS8510 มาตั้งแต่รุ่นก่อน) and แค่ยื่นรูปประโยคใหม่ให้ตัวตรวจตัวเดิม และเพราะ pattern เทียบได้เฉพาะค่าคงที่ที่คนเขียนพิมพ์เอง สิ่งที่มันจับได้จึงเป็น การพิมพ์ผิด ไม่ใช่ข้อผิดพลาดของการออกแบบ · อย่า ต่อ and ซ้อนกันเกินสองสามชั้นในกิ่งเดียว พออ่านไม่ออกแล้วมันแย่กว่า if ธรรมดา
Disjunctive or patterns เปลี่ยนการพิมพ์ ต่อหลาย pattern ด้วย or # link ถาวรของ Disjunctive or patterns
or รวม pattern หลายตัวให้ค่าตรงตัวใดตัวหนึ่งก็พอ ใช้แทนการเขียน == ต่อกันด้วย || หรือการเขียน case ซ้อนกันหลายบรรทัด
code เดิม
public static bool IsVowel (char c ) =>
c == 'a' || c == 'e' || c == 'i' || c == 'o' || c == 'u' ;
code ใหม่
public static bool IsVowel (char c ) =>
// รวมค่าที่ยอมรับได้เข้าเป็น pattern เดียว เอ่ยชื่อ c ครั้งเดียว
c is 'a' or 'e' or 'i' or 'o' or 'u' ; ใช้ตอนไหน ใช้กับรายการค่าที่ยอมรับได้สั้น ๆ ที่คงที่ เช่น ตัวคั่น รหัสสถานะ หรือชุดสถานะปลายทาง · ระวัง เมื่อรายการเริ่มยาวเกินหนึ่งบรรทัดหรือเริ่มเปลี่ยนตาม config — ตอนนั้นควรเก็บเป็นข้อมูล (HashSet<T>) ไม่ใช่ฝังไว้ในไวยากรณ์ที่ต้อง compile ใหม่ทุกครั้งที่แก้
Negated not patterns เปลี่ยนการพิมพ์ กลับเงื่อนไขของ pattern ด้วย not # link ถาวรของ Negated not patterns
not กลับผลของ pattern ที่ตามหลังมัน รูปที่ใช้บ่อยที่สุดคือ is not null ซึ่งอ่านออกทันทีโดยไม่ต้องครอบนิพจน์ทั้งก้อนด้วย !
code เดิม
// != เป็นตัวดำเนินการที่ overload ทับได้ ชนิดที่ทำแบบนั้นจะไม่ได้เทียบตัวตนจริง
public static bool HasValue (string ? text ) => text != null ;
code ใหม่
// อ่านจากซ้ายไปขวาได้ตรง ๆ และไม่เรียกตัวดำเนินการ != ที่ใครบางคน overload ทับไว้
public static bool HasValue (string ? text ) => text is not null ; ใช้ตอนไหน ใช้แทน != null โดยเฉพาะกับชนิดที่ overload ตัวดำเนินการเปรียบเทียบไว้ เพราะ is not null เทียบตัวตนตรง ๆ เสมอ · ระวัง การซ้อน not กับ or โดยไม่ใส่วงเล็บ เพราะ not ผูกแน่นกว่า: x is not 1 or 2 อ่านเป็น (not 1) or 2 ซึ่งให้ผลเท่ากับ not 1 เฉย ๆ กิ่ง or 2 ตายทิ้งไปโดยไม่มีใครเตือน · สิ่งที่คนตั้งใจจะเขียนคือ x is not (1 or 2)
Relational patterns เปลี่ยนการพิมพ์ เทียบมากกว่า/น้อยกว่าใน pattern ได้ # link ถาวรของ Relational patterns
pattern รับ <, >, <=, >= เทียบกับค่าคงที่ได้แล้ว จากเดิมที่เทียบได้แค่ 'เท่ากับค่าคงที่นี้' อย่างเดียว
code เดิม
public static string Tier (Money total )
{
if (total.Amount < 100m ) return "ไม่มีส่วนลดค่าส่ง" ;
if (total.Amount < 500m ) return "ส่งฟรีในรัศมี 3 กม." ;
return "ส่งฟรีทุกระยะ" ;
}
code ใหม่
public static string Tier (Money total ) => total.Amount switch
{
// เทียบด้วย < > <= >= ได้ใน pattern ไม่จำกัดแค่ 'เท่ากับค่าคงที่'
< 100m => "ไม่มีส่วนลดค่าส่ง" ,
< 500m => "ส่งฟรีในรัศมี 3 กม." ,
_ => "ส่งฟรีทุกระยะ" ,
}; ใช้ตอนไหน ใช้กับตารางขั้นบันได เช่น ช่วงราคา ช่วงเวลา หรือระดับความเสี่ยง เพราะเขียนเรียงจากน้อยไปมากแล้วเห็นขั้นทั้งหมดในที่เดียว · ระวัง ลำดับของกิ่ง เพราะกิ่งแรกที่ตรงเป็นผู้ชนะ เรียงสลับแล้วกิ่งหลังกลายเป็น code ที่ไปไม่ถึง · เทียบได้กับค่าที่ compiler รู้ตั้งแต่ตอน compile เท่านั้น: const ใช้ได้ แต่ static readonly หรือค่าที่อ่านมาจาก config ตอนรันได้ error CS9135 — เกณฑ์ที่ต้องปรับได้ตอนรันจึงยังต้องเป็น if เหมือนเดิม
Module initializers เปลี่ยนการพิมพ์ code ที่รันครั้งเดียวตอนโหลด module # link ถาวรของ Module initializers
ติด [ModuleInitializer] ให้ method static ที่ไม่มี parameter และคืน void แล้ว runtime จะเรียกมันครั้งเดียวตอนโหลด module ก่อน code อื่นใน module นั้นจะได้ทำงาน
code เดิม
ไม่มี code เดิมให้เทียบ — ผลอยู่ที่ compiler ไม่ใช่ที่ไวยากรณ์
code ใหม่
// เรียกครั้งเดียวตอนโหลด module ก่อน code อื่นใน module ได้ทำงาน
// เงื่อนไข: static · ไม่มี parameter · คืน void · เข้าถึงได้จากระดับ module
[System .Runtime .CompilerServices .ModuleInitializer ]
internal static void Warmup ()
{
AppContext.SetSwitch ("kaen.warmed" , true );
} ใช้ตอนไหน จุดที่คุ้มจริงคือ source generator และ library ที่ต้องลงทะเบียนอะไรบางอย่างก่อนใครแตะ · อย่า ใช้เป็นที่ตั้งค่า app: ลำดับการเรียกข้าม module ไม่มีการรับประกัน debug ยากเพราะไม่มีใครเรียกมันตรง ๆ และถ้ามันโยน exception app ตายตั้งแต่โหลด · ใน code application ปกติ Main ที่เรียกอะไรชัด ๆ ดีกว่าเสมอ
Extended partial methods เปลี่ยนการพิมพ์ partial method มีค่าคืน out และตัวขยายการเข้าถึงได้ # link ถาวรของ Extended partial methods
เดิม partial method ต้องเป็น void ห้ามมี out และห้ามใส่ตัวขยายการเข้าถึง เพราะถ้าไม่มีใครเขียนตัวจริง compiler จะลบการเรียกทิ้ง C# 9 ปลดข้อจำกัดทั้งหมด แลกกับกฎใหม่ว่ารูปแบบที่ใส่ตัวขยายการเข้าถึงแล้ว ต้อง มีตัวจริงเสมอ
code เดิม
public partial class Validator
{
private bool _valid ;
// แบบเดิมต้อง void ห้ามใส่ public — ผลลัพธ์ต้องส่งออกทาง field แทน
partial void Check (string input );
public bool IsValid (string input ) { _valid = false ; Check (input); return _valid; }
}
public partial class Validator
{
partial void Check (string input ) => _valid = input.Length > 0 ;
}
code ใหม่
public partial class Validator
{
// มีค่าคืนและใส่ public ได้ แลกกับข้อบังคับว่าอีกฝั่งต้องเขียนตัวจริงเสมอ
public partial bool IsValid (string input );
}
public partial class Validator
{
public partial bool IsValid (string input ) => input.Length > 0 ;
} ใช้ตอนไหน feature นี้มีไว้ให้ source generator เป็นหลัก — คนเขียนประกาศลายเซ็นไว้ เครื่องเติมตัวจริงให้ · ระวัง ความต่างที่สำคัญ: รูปแบบเดิมที่เป็น void เปล่า ๆ ถ้าไม่มีใครเขียนตัวจริง compiler ลบการเรียกทิ้งเงียบ ๆ แต่รูปแบบใหม่ที่ใส่ตัวขยายการเข้าถึงแล้วไม่มีตัวจริง จะ compile ไม่ผ่านทันที · อย่า เขียนเองสองฝั่งใน code ที่คนดูแล มันคือการแยกลายเซ็นออกจาก body โดยไม่ได้อะไรตอบแทน
Target-typed new expressions เปลี่ยนการพิมพ์ ละชื่อชนิดหลัง new ได้เมื่อปลายทางบอกชนิดอยู่แล้ว # link ถาวรของ Target-typed new expressions
เขียน new() โดยไม่ต้องเอ่ยชื่อชนิดซ้ำ เมื่อ compiler รู้ชนิดปลายทางอยู่แล้วจากการประกาศตัวแปร field parameter หรือชนิดคืนค่า
code เดิม
public static int Count ()
{
var byKey = new Dictionary <string , List <int >>();
byKey["kaen" ] = new List <int > { 1 , 2 , 3 };
return byKey["kaen" ].Count;
}
code ใหม่
public static int Count ()
{
// ชนิดอยู่ทางซ้ายแล้ว ไม่ต้องพิมพ์ซ้ำทางขวา
Dictionary <string , List <int >> byKey = new ();
byKey["kaen" ] = new () { 1 , 2 , 3 };
return byKey["kaen" ].Count;
} ใช้ตอนไหน คุ้มที่สุดกับ field และ property ที่ชนิด generic ยาว ๆ เพราะชื่อชนิดยังอยู่ครบทางซ้าย · อย่า ใช้คู่กับ var สลับกันใน file เดียว: var x = new Foo() บอกชนิดทางขวา ส่วน Foo x = new() บอกทางซ้าย ปนกันแล้วสายตาต้องไล่หาชนิดสองที่ · ระวัง จุดที่ปลายทางกำกวม เช่น argument ของ method ที่ overload ไว้หลายตัว
Static anonymous functions เปลี่ยนการพิมพ์ lambda ที่ห้ามจับตัวแปรข้างนอก # link ถาวรของ Static anonymous functions
ติด static ให้ lambda หรือ anonymous method เพื่อห้ามมันอ้างถึงตัวแปรในสโคปที่ครอบอยู่หรือ this — เป็นคู่ขนานของ static local function ที่มาใน C# 8
code เดิม
public static int Apply (int value )
{
int factor = 3 ;
// ไม่มีอะไรห้าม lambda จับ factor เข้าไป แก้ factor ทีหลังแล้วผลเปลี่ยนเงียบ ๆ
Func <int , int > scale = x => x * factor;
return scale (value);
}
code ใหม่
public static int Apply (int value )
{
int factor = 3 ;
// static = ห้ามจับ factor เข้ามา input ทุกตัวต้องผ่าน parameter
Func <int , int , int > scale = static (x , f ) => x * f;
return scale (value, factor);
} ใช้ตอนไหน ติดให้ lambda ที่ตั้งใจให้เป็น function บริสุทธิ์ เช่น ตัวเทียบใน Sort หรือ selector ของ LINQ ที่ไม่ควรพึ่งอะไรนอก argument · ระวัง ข้ออ้างเรื่องความเร็ว: lambda ที่ไม่จับตัวแปรอะไรเลยถูก cache เป็น delegate ตัวเดียวอยู่แล้วตั้งแต่ก่อนมีคำว่า static — คำนี้ไม่ได้ทำให้เร็วขึ้น มันแค่ย้ายกฎ 'ห้ามพึ่งของนอกสโคป' จากวินัยของทีมมาเป็นข้อบังคับของ compiler
Target-typed conditional expressions เปลี่ยนการพิมพ์ นิพจน์ ?: ที่สองกิ่งไม่มีชนิดร่วมกันก็ยังใช้ได้ # link ถาวรของ Target-typed conditional expressions
เดิมสองกิ่งของ ?: ต้องมีชนิดร่วมกันชนิดหนึ่งเสมอ C# 9 ยอมให้ไม่มีชนิดร่วมได้ ขอแค่ทั้งสองกิ่งแปลงเป็นชนิดปลายทางที่ compiler รู้อยู่แล้วได้
code เดิม
public static int ? Read (bool valid , int value )
{
// int กับ null ไม่มีชนิดร่วมกัน ต้อง cast กิ่งใดกิ่งหนึ่งบอกชนิดเอง
int ? result = valid ? (int ?)value : null ;
return result;
}
code ใหม่
public static int ? Read (bool valid , int value )
{
// ชนิดปลายทางคือ int? ทั้งสองกิ่งแปลงเข้าหามันได้ จึงไม่ต้อง cast
int ? result = valid ? value : null ;
return result;
} ใช้ตอนไหน ตัดการ cast ที่ใส่ไว้แค่เพื่อให้ compiler ยอม โดยเฉพาะกับ nullable และกับ class พี่น้องที่มีฐานร่วมกัน · ระวัง ตอนที่ปลายทางเป็น var หรือเป็น argument ของ method ที่ overload ไว้ — ไม่มีชนิดปลายทางให้ยึด compiler ก็ยังฟ้อง error CS0173 เหมือนเดิม และข้อความมันพูดว่า 'ไม่มี implicit conversion ระหว่างสองกิ่ง' ซึ่งชวนให้เข้าใจผิดว่า feature นี้ไม่ทำงาน ทั้งที่ปัญหาคือฝั่งซ้ายไม่ได้บอกชนิด
Extension GetEnumerator support for foreach loops เปลี่ยนการพิมพ์ foreach มองเห็น GetEnumerator ที่เป็น extension method # link ถาวรของ Extension GetEnumerator support for foreach loops
foreach ยอมรับ GetEnumerator ที่มาจาก extension method แล้ว จึงวนซ้ำบนชนิดที่เราแก้ต้นฉบับไม่ได้ได้โดยไม่ต้องห่อด้วย method ตัวกลาง
code เดิม
public static class RangeDemo
{
// Range วนซ้ำเองไม่ได้ ต้องมี method ตัวกลางคั่นไว้เสมอ
static IEnumerable <int > To (int a , int b ) { for (int i = a; i < b; i++ ) yield return i; }
public static int Sum () { int t = 0 ; foreach (int i in To (1 , 5 )) t += i; return t; }
}
code ใหม่
public static class RangeEnumerable
{
// ประกาศไว้ข้างนอก Range แต่ foreach มองเห็นและเรียกใช้ได้
public static IEnumerator <int > GetEnumerator (this Range r )
{ for (int i = r.Start.Value; i < r.End.Value; i++ ) yield return i; }
}
public static class RangeDemo
{
// วนบน Range ได้ตรง ๆ โดยไม่ต้องมี method ตัวกลาง
public static int Sum () { int t = 0 ; foreach (int i in 1 .. 5 ) t += i; return t; }
} ใช้ตอนไหน ใช้ตอนอยากให้ชนิดของคนอื่นวนซ้ำได้โดยไม่ต้องแก้ต้นฉบับและไม่ต้องห่อ · อย่า ทำให้ชนิดที่คนทั้ง project ใช้ร่วมกันวนซ้ำได้ด้วยวิธีนี้ เพราะ foreach จะเปลี่ยนความหมายตามว่า using ตัวไหนอยู่ในขอบเขต — คนอ่าน code ใน file อื่นจะไม่มีทางรู้ว่ามันวนอะไรอยู่ · เก็บ extension ไว้ในเนมสเปซแคบ ๆ ที่ตั้งใจ import เท่านั้น
Lambda discard parameters เปลี่ยนการพิมพ์ parameter ที่ไม่ใช้ตั้งชื่อเป็น _ ซ้ำกันได้ # link ถาวรของ Lambda discard parameters
ใน lambda และ anonymous method ใช้ _ เป็นชื่อ parameter ได้มากกว่าหนึ่งตัว compiler ถือว่าทุกตัวคือ discard ไม่ใช่ชื่อที่ชนกัน
code เดิม
public static EventHandler Ignore () =>
// ต้องตั้งชื่อให้ครบทั้งสองตัวถึงจะ compile ผ่าน แม้จะไม่ได้ใช้เลยสักตัว
(sender , args ) => Console.WriteLine ("ทำงานแล้ว" );
code ใหม่
public static EventHandler Ignore () =>
// _ สองตัวคือ discard ไม่ใช่ชื่อที่ชนกัน อ่านแล้วรู้ทันทีว่าไม่ได้ใช้
(_ , _ ) => Console.WriteLine ("ทำงานแล้ว" ); ใช้ตอนไหน ใช้กับตัวจัดการ event และ callback ที่ลายเซ็นบังคับให้รับ parameter ที่ไม่ได้ใช้ · ระวัง lambda ที่มี _ ตัวเดียว มันยังเป็นชื่อ parameter ปกติที่อ้างถึงได้ (กฎ discard เริ่มมีผลตั้งแต่ตัวที่สองขึ้นไป) ดังนั้นการอ่าน _ ว่า 'ไม่ได้ใช้แน่ ๆ' ผิดในกรณีนั้น
Attributes on local functions เปลี่ยนการพิมพ์ ติดแอตทริบิวต์ให้ local function ได้ # link ถาวรของ Attributes on local functions
ติดแอตทริบิวต์ให้ local function parameter ของมัน และค่าคืนของมันได้แล้ว รวมถึงแอตทริบิวต์สาย nullable analysis ที่ compiler อ่านไปใช้จริง
code เดิม
public static bool TryCount (string ? raw , out int value )
{
return Parse (raw, out value);
// ติดแอตทริบิวต์ให้ local function ไม่ได้ ถ้าอยากประกาศสัญญาเรื่อง null
// ต้องย้ายออกไปเป็น method เต็มของ class
static bool Parse (string ? text , out int result ) => int .TryParse (text, out result);
}
code ใหม่
public static bool TryCount (string ? raw , out int value )
{
return Parse (raw, out value);
// ประกาศสัญญาได้ตรงที่ code อยู่ ไม่ต้องย้ายออกไปเป็น method เต็ม
static bool Parse (
[System .Diagnostics .CodeAnalysis .NotNullWhen (true )] string ? text ,
out int result ) => int .TryParse (text, out result);
} ใช้ตอนไหน ประโยชน์จริงอยู่ที่แอตทริบิวต์ที่ compiler อ่าน เช่น NotNullWhen หรือ DoesNotReturn ที่ทำให้การวิเคราะห์ null ใน method ที่ครอบอยู่แม่นขึ้น · อย่า คาดหวังผลจากแอตทริบิวต์ที่ต้องอ่านผ่าน reflection ตอนรัน เพราะชื่อของ local function ที่ compiler สังเคราะห์ไม่มีการรับประกันและไม่มีใครค้นเจอตามชื่อ
Native sized integers ประสิทธิภาพ/interop จำนวนเต็มที่กว้างเท่า pointer ของ platform # link ถาวรของ Native sized integers
nint และ nuint คือจำนวนเต็มที่กว้าง 32 หรือ 64 bit ตาม platform ที่รัน ใช้ตัวดำเนินการเลขคณิตและการเปรียบเทียบได้ตรง ๆ เหมือน int
code เดิม
public static long Stride (int count , int itemSize )
{
// เดิม IntPtr คูณไม่ได้และเทียบมากกว่า/น้อยกว่าไม่ได้
// ต้องแปลงออกเป็น long คิดเลข แล้วแปลงกลับเข้ามาทุกครั้ง
IntPtr total = new IntPtr ((long )count * itemSize);
return total.ToInt64 () > 0 ? total.ToInt64 () : 0 ;
}
code ใหม่
public static long Stride (int count , int itemSize )
{
// nint คูณ เทียบ และเขียนเป็นตัวเลขได้ตรง ๆ โดยกว้างเท่า pointer ของ platform
nint total = (nint )count * itemSize;
return total > 0 ? total : 0 ;
} ใช้ตอนไหน ใช้กับ interop และโครงสร้างข้อมูลที่ต้องเก็บ offset หรือขนาดที่ผูกกับความกว้างของ pointer · อย่า เอามาใช้แทน int ใน code ธุรกิจเพราะคิดว่า 'กว้างกว่าน่าจะปลอดภัยกว่า' — ค่าที่ล้นจะล้นไม่เหมือนกันระหว่าง 32 กับ 64 bit แปลว่า test ที่ผ่านบนเครื่องพัฒนาไม่ได้การันตีเป้าหมายจริง · และช่องว่างนี้แคบลงมากแล้ว: ตั้งแต่ .NET 7 ตัว IntPtr เองก็ได้ตัวดำเนินการเลขคณิตและการเปรียบเทียบครบชุดเหมือนกัน snippet ฝั่งซ้ายจึงเป็นภาพของโลกตอน feature นี้ออก ไม่ใช่ข้อจำกัดของ IntPtr วันนี้
Function pointers ประสิทธิภาพ/interop pointer ไปยัง function โดยไม่ต้องมี object delegate # link ถาวรของ Function pointers
delegate*<...> คือ pointer ไปยัง method static ที่เรียกด้วยคำสั่ง IL calli ตรง ๆ ไม่มี object delegate ให้จัดสรร ใช้ได้เฉพาะในบริบท unsafe
code เดิม
private static int Twice (int x ) => x * 2 ;
public static int Double ()
{
// ต้องสร้าง object delegate ขึ้นมาห่อ method ก่อนถึงจะส่งต่อหรือเรียกได้
Func <int , int > op = Twice;
return op (21 );
}
code ใหม่
private static int Twice (int x ) => x * 2 ;
public static unsafe int Double ()
{
// ไม่มี object delegate ให้จัดสรร แลกกับต้องอยู่ในบริบท unsafe
delegate *<int, int> op = & Twice;
return op (21 );
} ใช้ตอนไหน ใช้ในเส้นทางร้อนที่ต้องเรียก callback หลายล้านครั้ง และในการรับ callback จาก code native · อย่า แตะถ้ายังไม่ได้วัด: มันบังคับให้ทั้ง project เปิด AllowUnsafeBlocks เรียกได้เฉพาะ method static และ compiler ไม่ตรวจให้เลยว่า pointer ที่ถืออยู่ยังชี้ไปที่ของจริง
SkipLocalsInit attribute ประสิทธิภาพ/interop สั่งข้ามการล้างหน่วยความจำของตัวแปรท้องถิ่น # link ถาวรของ SkipLocalsInit attribute
[SkipLocalsInit] สั่งไม่ให้ compiler ปล่อยธง localsinit ที่บอก runtime ให้ล้างพื้นที่ตัวแปรท้องถิ่นเป็นศูนย์ก่อนเข้า method · ตัวแอตทริบิวต์เองไม่ใช่ไวยากรณ์ใหม่ จึง compile ผ่านที่ version ก่อนหน้าได้ ต่างกันที่ compiler version เก่าไม่ทำอะไรกับมัน
code เดิม
ไม่มี code เดิมให้เทียบ — ผลอยู่ที่ compiler ไม่ใช่ที่ไวยากรณ์
code ใหม่
[System .Runtime .CompilerServices .SkipLocalsInit ]
public static int Sum ()
{
// ไม่มีการล้างเป็นศูนย์ให้แล้ว ต้องเขียนค่าเองให้ครบก่อนอ่านทุกช่อง
Span <int > buffer = stackalloc int [64 ];
for (int i = 0 ; i < buffer.Length; i++ ) buffer[i] = i;
int total = 0 ;
foreach (int v in buffer) total += v;
return total;
} ใช้ตอนไหน คุ้มเฉพาะกับ method ที่ stackalloc buffer ใหญ่ใน loop ร้อน ซึ่งค่าล้างศูนย์ต่อรอบวัดได้จริง · อย่า ติดไว้ทั้ง assembly: ทุกช่องที่อ่านก่อนเขียนจะได้ขยะจากสแต็กแทนศูนย์ ซึ่งกลายเป็นทั้ง bug ที่ขึ้นกับจังหวะและช่องทางรั่วของข้อมูลเก่า · ต้องเปิด AllowUnsafeBlocks ด้วยถึงจะใช้ได้
C# 10 — 16 feature
Record structs เปลี่ยนการออกแบบ record ที่เป็น value type # link ถาวรของ Record structs
compiler รับประกันว่า: "ค่าเท่ากันคือสิ่งเดียวกัน" มาจาก compiler ชุดเดียว — Equals, GetHashCode และ == ถูกสังเคราะห์จากรายการสมาชิกเดียวกันจึงตอบตรงกันเสมอ ต่างจาก struct ธรรมดาที่มี Equals ตัวเริ่มต้นมาให้แต่ไม่มี == เลยจนกว่าจะเขียนเอง แล้วสองทางนั้นเพี้ยนจากกันเงียบ ๆ ตอนมีคนเพิ่ม field
record struct และ readonly record struct สังเคราะห์ Equals, GetHashCode, ToString, ==/!= และ Deconstruct (เฉพาะแบบ positional) ให้ครบชุดเหมือน record แต่ตัวชนิดยังเป็น struct จึงไม่ขึ้น heap
code เดิม
public readonly struct OrderId : IEquatable <OrderId >
{
public OrderId (int value ) => Value = value;
public int Value { get ; }
// ต้องเขียนเองห้าที่ และต้องคอยให้ทุกที่ตรงกันทุกครั้งที่เพิ่ม field
public bool Equals (OrderId other ) => Value == other.Value;
public override bool Equals (object ? obj ) => obj is OrderId o && Equals (o);
public override int GetHashCode () => Value.GetHashCode ();
public static bool operator == (OrderId a , OrderId b ) => a.Equals (b);
public static bool operator != (OrderId a , OrderId b ) => ! a.Equals (b);
}
code ใหม่
// ได้ Equals/GetHashCode/==/!=/ToString/Deconstruct ครบชุด โดยไม่ขึ้น heap
public readonly record struct OrderId (int Value );
public static OrderId IdOf (Order order ) => new (order.Id); ใช้ตอนไหน ใช้กับ id ที่มีชนิดของตัวเอง (strongly-typed id) และ value object ก้อนเล็กที่ถูกสร้างและเทียบบ่อยจนไม่อยากให้ทุกครั้งไปกินพื้นที่บน heap · ระวัง record struct เปล่า ๆ แก้ค่าได้ parameter positional ของมันคือ property ที่มีทั้ง get และ set เต็มตัว (h.V = 2; compile ผ่าน) ถ้าอยากได้ของที่แช่แข็งต้องเขียน readonly record struct เสมอ · อย่า ใช้กับก้อนใหญ่ เพราะ struct ถูกคัดลอกทุกครั้งที่ส่งต่อ ยิ่งสมาชิกเยอะยิ่งแพงกว่าการส่ง reference
Improvements of structure types เปลี่ยนการออกแบบ struct มี constructor ไม่มี parameter และค่าเริ่มต้นของ field ได้ # link ถาวรของ Improvements of structure types
compiler รับประกันว่า: ค่าเริ่มต้นที่ ถูกต้อง ของ struct เขียนไว้ในตัวชนิดได้แล้ว และ compiler บังคับให้ new T() เดินผ่านมันเสมอ — แต่ต้องอ่านขอบเขตให้ครบ: default(T) ช่องในอาร์เรย์ และ field ที่ยังไม่ถูกเขียน ยังข้ามไปได้ศูนย์เหมือนเดิม มันจึงเลื่อนเส้นการรับประกันขึ้นมาหนึ่งขั้น ไม่ได้ปิดช่อง
struct ประกาศ constructor ที่ไม่มี parameter ได้แล้ว และใส่ initializer ให้ field กับ property ได้ · ชุดเดียวกันนี้ยังทำให้นิพจน์ with ใช้กับ struct และ anonymous type ได้ด้วย
code เดิม
public struct Weight
{
// struct ประกาศ ctor ไม่มี parameter ไม่ได้ และใส่ค่าเริ่มต้นให้ property ไม่ได้
public double Kilograms { get ; set ; }
}
// ต้องมี method โรงงานคอยประกอบให้ และไม่มีอะไรห้ามใครเรียก new Weight() ตรง ๆ
public static Weight Fresh () => new Weight { Kilograms = 1.0 };
code ใหม่
public struct Weight
{
public Weight () { } // ประกาศได้แล้ว
public double Kilograms { get ; set ; } = 1.0 ; // และมีค่าเริ่มต้นได้
}
// new Weight() เดินผ่าน ctor จริง จึงได้ 1.0 — แต่ default(Weight) ยังได้ 0.0
public static double Fresh () => new Weight ().Kilograms; ใช้ตอนไหน ใช้ตอนที่ค่าเริ่มต้นที่ถูกของ struct ไม่ใช่ 'ศูนย์ทั้งก้อน' เช่น ตัวคูณที่ต้องเริ่มที่ 1 หรือความจุที่ต้องเริ่มที่ 16 · อย่า อ่านว่ามันปิดช่องค่าศูนย์ได้: default(Weight), new Weight[10] และ field ของ class ที่ยังไม่ถูกเขียน ล้วนข้าม ctor ตัวนี้ทั้งหมด ถ้าสถานะศูนย์เป็นสถานะที่ผิดจริง ๆ ต้องใช้ class/record ที่บังคับผ่าน constructor ได้ · ระวัง ctor ตัวนี้ต้องเป็น public เท่านั้น ใส่ private แล้วได้ error CS8958 ทันที
Improved definite assignment and null-state analysis เปลี่ยนการออกแบบ compiler วิเคราะห์ 'ค่านี้ถูกกำหนดแล้วหรือยัง' ได้แม่นขึ้น # link ถาวรของ Improved definite assignment and null-state analysis
compiler รับประกันว่า: compiler พิสูจน์ได้แล้วว่านิพจน์ prices?.TryGetValue(key, out int found) == true เป็นจริงได้ทางเดียวคือ method ถูกเรียกจริง ดังนั้น found ถูกกำหนดค่าแน่นอน — ความจริงข้อนี้เคยพิสูจน์ไม่ได้ เราจึงต้องยัดค่าเริ่มต้นหลอก ๆ ให้ตัวแปรเพื่อปิดปาก CS0165 ซึ่งเป็นการปิดเสียงที่กลบเคสที่ยังไม่ได้กำหนดค่าจริง ๆ ไปด้วย
compiler ตามรอยรูปประโยคที่เคยตามไม่ทันได้แล้ว — ?. ที่จบด้วยการเทียบกับ true/false, pattern is ที่ผูกกับ &&/|| และ ??/??= — code ที่ถูกอยู่แล้วจึงเลิกโดน CS0165 โดยที่ไม่มีไวยากรณ์ใหม่ให้เขียนเลยสักตัว
code เดิม
ไม่มี code เดิมให้เทียบ — ผลอยู่ที่ compiler ไม่ใช่ที่ไวยากรณ์
code ใหม่
public static int Lookup (Dictionary <string , int >? prices , string key )
{
// เดิมบรรทัดนี้ได้ error CS0165 เพราะ compiler ตามไม่ทันว่าเงื่อนไขจะเป็นจริงได้
// ก็ต่อเมื่อ TryGetValue ถูกเรียกจริง ซึ่งแปลว่า found ถูกกำหนดค่าแล้วแน่นอน
if (prices? .TryGetValue (key, out int found ) == true ) return found;
return 0 ;
} ใช้ตอนไหน ได้มาฟรีตอนอัปเกรด ไม่ต้องแก้ code สักบรรทัด และเป็นโอกาสไล่ลบค่าเริ่มต้นหลอก ๆ ที่เคยใส่ไว้กล่อม compiler ทิ้ง · ระวัง สองเรื่อง: หนึ่ง มันติดมากับ ตัว compiler ไม่ใช่กับสวิตช์ <LangVersion> — snippet นี้ compile ผ่านที่ LangVersion 9 ด้วยซ้ำถ้าใช้ SDK ใหม่ ที่ต่างกันคือรุ่นของ SDK ล้วน ๆ สอง มันแม่นขึ้นทั้งสองทาง code ที่เคยเงียบอาจโผล่ warning null-state ใหม่หลังอัปเกรด ซึ่งเป็นคำเตือนที่ถูก ไม่ใช่ของเสีย
Global using directives เปลี่ยนการพิมพ์ ประกาศ using ครั้งเดียวให้มีผลทั้ง project # link ถาวรของ Global using directives
global using X; ใน file ใด file หนึ่งมีผลกับทุก file ใน project นั้น · template ของ SDK ใช้กลไกเดียวกันนี้ผ่าน <ImplicitUsings>enable</ImplicitUsings> ซึ่งใส่ชุดมาตรฐานให้โดยไม่ต้องเขียนเอง
code เดิม
using System .Text ; // ต้องเขียนซ้ำในทุก file ที่ใช้ StringBuilder
public static class Report
{
public static string Empty () => new StringBuilder ().ToString ();
}
code ใหม่
global using System .Text ;
// file อื่นทั้ง project เรียก StringBuilder ได้เลย โดยไม่ต้องมี using ของตัวเอง
public static class Report
{
public static string Empty () => new StringBuilder ().ToString ();
} ใช้ตอนไหน เก็บไว้ใน file เดียวชื่อชัด ๆ (เช่น GlobalUsings.cs) และใส่เฉพาะเนมสเปซที่ ทุก file จริง ๆ ใช้ เช่น System และเนมสเปซหลักของ domain ตัวเอง · อย่า ยัดทุกเนมสเปซที่เคยใช้เข้าไป เพราะ file ที่เหลือจะไม่มีอะไรบอกว่าชื่อที่โผล่มานั้นมาจากไหน คนอ่านผ่าน GitHub และเครื่องมือค้นหาต้องเดาเอา · ระวัง ลำดับใน file: global using ต้องอยู่ก่อน using ธรรมดาทุกตัว ไม่งั้นได้ error CS8915
File-scoped namespace declaration เปลี่ยนการพิมพ์ ประกาศ namespace ทั้ง file ด้วยบรรทัดเดียว # link ถาวรของ File-scoped namespace declaration
เขียน namespace X; แทนการครอบทั้ง file ด้วยปีกกา ลดการเยื้องของทุกบรรทัดใน file ลงหนึ่งระดับ
code เดิม
namespace Kaen .Ordering
{
public sealed record Money (decimal Amount , string Currency );
}
code ใหม่
namespace Kaen .Ordering ;
public sealed record Money (decimal Amount , string Currency ); ใช้ตอนไหน เปิดใช้ทั้ง codebase พร้อมกันได้เลยด้วย dotnet format เพราะไม่เปลี่ยนความหมายของ code สักบรรทัด · ยกเว้น file ที่ตั้งใจมีมากกว่า1 namespace ซึ่งใช้ไวยากรณ์นี้ไม่ได้
Extended property patterns เปลี่ยนการพิมพ์ ลงลึกหลายชั้นใน pattern ด้วยจุด # link ถาวรของ Extended property patterns
เขียน { Total.Amount: > 1000m } ได้แล้ว แทนที่จะต้องซ้อนปีกกาให้ครบทุกชั้นเป็น { Total: { Amount: > 1000m } } — ความหมายเหมือนกันทุกประการ รวมถึงการเช็ค null ให้ทีละชั้น
code เดิม
public static bool IsBigOrder (Order order ) =>
// ต้องซ้อนปีกกาให้ครบตามความลึกของ property
order is { Status: OrderStatus .Placed , Total: { Amount: > 1000m } };
code ใหม่
public static bool IsBigOrder (Order order ) =>
// จุดลงไปได้ตรง ๆ ไม่ต้องนับปีกกาปิด
order is { Status: OrderStatus .Placed , Total.Amount: > 1000m }; ใช้ตอนไหน คุ้มที่สุดกับ pattern ที่ลึกสองชั้นขึ้นไปและมีเงื่อนไขหลายตัวในระดับเดียวกัน เพราะตัดปีกกาปิดที่ต้องนับออกไปได้ · ระวัง ว่ามันทำให้เขียนจุดยาว ๆ ได้ง่ายขึ้นด้วย ยิ่งจุดยาว code ยิ่งผูกกับรูปร่างข้างในของ object อื่น เปลี่ยนโครงสร้างเมื่อไรพังทุกที่ที่จุดลึก · และค่าที่เทียบยังต้องเป็นค่าคงที่ตอน compile เหมือนเดิม
Natural type of a lambda expression เปลี่ยนการพิมพ์ lambda มีชนิดของตัวเองโดยไม่ต้องบอกชนิด delegate # link ถาวรของ Natural type of a lambda expression
compiler อนุมานชนิด delegate ให้ lambda และ method group ได้เอง (เป็น Func<>/Action<> เมื่อรูปแบบเข้ากันได้) จึงกำหนดค่าให้ var หรือส่งเข้า parameter ชนิด Delegate ได้
code เดิม
public static Delegate Doubler ()
{
// var ใช้กับ lambda ไม่ได้ ต้องเอ่ยชนิด delegate ให้ครบก่อนเสมอ
Func <int , int > twice = x => x * 2 ;
return twice;
}
code ใหม่
public static Delegate Doubler ()
{
// compiler อนุมานเองว่านี่คือ Func<int, int>
var twice = (int x ) => x * 2 ;
return twice;
} ใช้ตอนไหน ได้ประโยชน์จริงกับ API ที่รับ Delegate เช่นการลงทะเบียน endpoint ของ minimal API ที่เดิมต้องแปลงชนิดให้เองก่อน · ระวัง ว่าไม่ใช่ทุก lambda จะมีชนิดของตัวเอง var f = () => null; ได้ error CS8917: The delegate type could not be inferred เพราะไม่มีอะไรบอกชนิดคืนค่า · อย่า ใช้ var กับ lambda ใน code ที่คนอื่นต้องอ่านต่อ ชนิดที่หายไปคือสิ่งเดียวที่บอกว่าลายเซ็นคืออะไร
Explicit return type on a lambda expression เปลี่ยนการพิมพ์ ระบุชนิดคืนค่าให้ lambda เองได้ # link ถาวรของ Explicit return type on a lambda expression
เขียนชนิดคืนค่าไว้หน้ารายการ parameter ของ lambda ได้ (object (string s) => ...) สำหรับกรณีที่ compiler อนุมานเองไม่ได้ หรืออนุมานได้ชนิดที่ไม่ตรงกับที่ตั้งใจ
code เดิม
public static object Parse (string raw )
{
// ต้อง cast กิ่งใดกิ่งหนึ่งเป็น object เอง compiler ถึงจะยอม
Func <string , object > parse = s => int .TryParse (s, out int n ) ? (object )n : "ไม่ใช่ตัวเลข" ;
return parse (raw);
}
code ใหม่
public static object Parse (string raw )
{
// บอกชนิดคืนค่าที่ตัว lambda เลย ไม่ต้อง cast กิ่งไหนอีก
var parse = object (string s ) => int .TryParse (s, out int n ) ? n : "ไม่ใช่ตัวเลข" ;
return parse (raw);
} ใช้ตอนไหน ใช้ตอนที่สองกิ่งของ lambda ให้ชนิดต่างกันจนอนุมานไม่ได้ หรือตอนที่อยากคืน interface แทนชนิดจริง · ระวัง ว่าพอเขียนชนิดคืนค่าแล้ว parameter ทุกตัวต้องเขียนชนิดกำกับด้วย (object (s) => ... ได้ error CS8917) · ไม่ควร ใช้บ่อย เพราะการที่ compiler อนุมานไม่ได้มักแปลว่า lambda ตัวนั้นกำลังทำสองอย่างที่ควรแยกกัน
Attributes on lambda expressions เปลี่ยนการพิมพ์ ติดแอตทริบิวต์ให้ lambda ได้ # link ถาวรของ Attributes on lambda expressions
ติดแอตทริบิวต์ให้ตัว lambda parameter ของมัน และค่าคืนของมันได้แล้ว เหมือนที่ทำกับ method เต็มมาตลอด — เป็นคู่ขนานของแอตทริบิวต์บน local function ที่มาในรุ่นก่อน
code เดิม
// ติดแอตทริบิวต์ให้ lambda ไม่ได้ ถ้าต้องการก็ต้องย้ายออกไปเป็น method เต็มก่อน
[System .Diagnostics .DebuggerStepThrough ]
private static int Twice (int x ) => x * 2 ;
public static Func <int , int > Doubler () => Twice;
code ใหม่
public static Func <int , int > Doubler () =>
// ติดที่ตัว lambda ได้ตรงจุดที่ code อยู่ ไม่ต้องย้ายไปไหน
[System.Diagnostics.DebuggerStepThrough] (int x ) => x * 2 ; ใช้ตอนไหน ใช้กับแอตทริบิวต์ที่เครื่องมืออ่านตอน compile หรือตอน debug เช่น DebuggerStepThrough หรือแอตทริบิวต์สาย nullable analysis · ระวัง รูปแบบ: ติดแอตทริบิวต์แล้วต้องใช้วงเล็บครบและเขียนชนิด parameter กำกับ · อย่า คาดหวังผลจากแอตทริบิวต์ที่ต้องค้นหาผ่าน reflection ตอนรัน เพราะ method ที่ compiler สังเคราะห์ขึ้นมาให้ lambda ไม่มีการรับประกันเรื่องชื่อ
Constant interpolated strings เปลี่ยนการพิมพ์ ประกอบค่าคงที่ string ด้วยไวยากรณ์ $"" ได้ # link ถาวรของ Constant interpolated strings
const string ใช้ string แบบ interpolate เป็นค่าเริ่มต้นได้ ถ้าช่องที่แทรกทุกช่องเป็น const string เหมือนกันหมด
code เดิม
public const string Brand = "Kaen" ;
// const ใช้ $"" ไม่ได้ ต้องต่อ string ด้วย + เท่านั้น
public const string UserAgent = Brand + "/1.0" ;
code ใหม่
public const string Brand = "Kaen" ;
// ประกอบด้วยรูปแบบเดียวกับ string ทั่วไป ค่ายังถูกคำนวณตอน compile เหมือนเดิม
public const string UserAgent = $"{ Brand}/1.0" ; ใช้ตอนไหน ใช้ให้ค่าคงที่ที่ประกอบจากค่าคงที่อื่นอ่านเป็นรูปเดียวกับ string ที่เหลือใน file · ระวัง ว่าช่องที่แทรกต้องเป็น const string เท่านั้น ใส่ const int ลงไปได้ error CS0133 ทันที เพราะการจัดรูปตัวเลขขึ้นกับวัฒนธรรม (culture) จึงคำนวณตอน compile ไม่ได้ · อย่า ลืมว่าค่า const ที่เป็น public ถูกฝังลงในผู้เรียกตอน compile แก้ค่าแล้วต้อง compile ผู้เรียกใหม่ทั้งหมด
Sealed ToString in record types เปลี่ยนการพิมพ์ ปิด ToString ของ record ไม่ให้ลูกเขียนทับ # link ถาวรของ Sealed ToString in record types
record ที่ override ToString ใส่ sealed ได้แล้ว — compiler จะเลิกสังเคราะห์ ToString ให้ record ลูก และห้ามลูกเขียนทับเอง
code เดิม
public record Product (string Name )
{
// ใส่ sealed ไม่ได้ ทุก record ลูกจะได้ ToString ที่สังเคราะห์ใหม่ทับของนี้เสมอ
public override string ToString () => Name;
}
code ใหม่
public record Product (string Name )
{
// sealed = รูปแบบข้อความของทั้งลำดับชั้นถูกตรึงไว้ที่นี่ที่เดียว
public sealed override string ToString () => Name;
} ใช้ตอนไหน ใช้กับ record ฐานที่ข้อความของมันถูกเอาไปใช้เป็นรูปแบบตายตัว เช่น key ของ log หรือรหัสที่ต้องหน้าตาเหมือนกันทุกชนิดลูก · อย่า ใช้กับ record ที่ตั้งใจให้ลูกอธิบายตัวเองได้ เพราะพอปิดแล้ว record ลูกจะพิมพ์ออกมาโดยไม่มีสมาชิกของตัวเองเลย ซึ่งทำให้ข้อความตอน debug หายไปเงียบ ๆ · กับ record ที่ sealed อยู่แล้ว คำนี้ไม่ผิดแต่ไม่ได้อะไรเพิ่ม
Assignment and declaration in the same deconstruction เปลี่ยนการพิมพ์ deconstruct ครั้งเดียว ประกาศตัวใหม่และเขียนทับตัวเดิมพร้อมกัน # link ถาวรของ Assignment and declaration in the same deconstruction
ในการ deconstruct ครั้งเดียวกัน ช่องหนึ่งประกาศตัวแปรใหม่และอีกช่องเขียนทับตัวแปรที่มีอยู่แล้วได้ จากเดิมที่ทุกช่องต้องเป็นแบบเดียวกันทั้งชุด
code เดิม
public static int Split (string raw )
{
int total = 0 ;
// ผสมกันไม่ได้ ต้องประกาศตัวพักไว้ก่อนแล้วค่อยเขียนกลับอีกบรรทัด
(string name , int length ) = (raw, raw.Length);
total = length;
return name.Length + total;
}
code ใหม่
public static int Split (string raw )
{
int total = 0 ;
// name ประกาศใหม่ · total เขียนทับตัวเดิม ใน statement เดียวกัน
(string name , total) = (raw, raw.Length);
return name.Length + total;
} ใช้ตอนไหน คุ้มตอนที่ค่าบางส่วนต้องไหลออกไปยังตัวแปรที่ประกาศไว้นอก loop หรือนอก if อยู่แล้ว จึงไม่ต้องมีตัวแปรพักที่มีอายุสามบรรทัด · ระวัง ว่าอ่านผ่าน ๆ แล้วแยกไม่ออกว่าช่องไหนคือของใหม่ ช่องไหนกำลังทับของเก่า ความต่างอยู่ที่ชื่อชนิดที่นำหน้าเท่านั้น · ไม่ควร ใช้กับ tuple ที่ยาวเกินสามช่อง
CallerArgumentExpression attribute เปลี่ยนการพิมพ์ รับข้อความของนิพจน์ที่ผู้เรียกส่งมาเป็น string # link ถาวรของ CallerArgumentExpression attribute
ติด [CallerArgumentExpression("ชื่อพารามิเตอร์")] ให้ parameter ที่มีค่าเริ่มต้น แล้ว compiler จะเติมข้อความของ argument ตัวนั้นตามที่ผู้เรียกพิมพ์ไว้ให้ · ตัวแอตทริบิวต์เองไม่ใช่ไวยากรณ์ใหม่ ทั้งหมดคือพฤติกรรมของ compiler
code เดิม
public static void Require (bool condition , string message )
{
// ต้องพิมพ์เงื่อนไขซ้ำเป็น string เอง แล้วมันจะค้างอยู่แบบนั้นตอน code เปลี่ยน
if (! condition) throw new ArgumentException ($"เงื่อนไขไม่ผ่าน: { message}" );
}
public static void Check (int quantity ) => Require (quantity > 0 , "quantity > 0" );
code ใหม่
public static void Require (bool condition ,
[System .Runtime .CompilerServices .CallerArgumentExpression ("condition" )] string ? expr = null )
{
// expr ได้ข้อความ "quantity > 0" มาเอง และเปลี่ยนตาม code จริงเสมอ
if (! condition) throw new ArgumentException ($"เงื่อนไขไม่ผ่าน: { expr}" );
}
public static void Check (int quantity ) => Require (quantity > 0 ); ใช้ตอนไหน จุดที่คุ้มที่สุดคือ method ตรวจเงื่อนไขและ assertion ของ test ซึ่ง ArgumentNullException.ThrowIfNull ใน library มาตรฐานก็ใช้กลไกนี้ · ระวัง ชื่อ parameter ที่ใส่ในแอตทริบิวต์: สะกดผิดแล้วไม่มี error มีแค่ warning CS8963 และค่าที่ได้จะเป็นค่าเริ่มต้นตลอดไป · อย่า ส่งข้อความที่ได้ออกไปให้ผู้ใช้ปลายทางหรือเก็บลง log ที่คนนอกอ่านได้ทั้งดุ้น เพราะมันคือ source code ของเราที่หลุดออกไปตามตัวอักษร
Interpolated string handlers ประสิทธิภาพ/interop ชนิดที่รับหน้าที่ประกอบ string แทนการต่อเป็น string # link ถาวรของ Interpolated string handlers
compiler แปลง $"..." ที่ส่งเข้า parameter ซึ่งเป็น handler ให้กลายเป็นการเรียก AppendLiteral/AppendFormatted ทีละชิ้น แทนที่จะต่อทุกชิ้นเป็น string ก้อนเดียวก่อนแล้วค่อยส่ง
code เดิม
// parameter เป็น string: ทุกครั้งที่เรียก string ถูกประกอบเสร็จก่อนเสมอ
// ต่อให้ปลายทางจะโยนทิ้งทันทีเพราะปิดการบันทึกอยู่ก็ตาม
public static void Run (string query ) { }
public static void Demo (int id ) => Run ($"select * from orders where id = { id}" );
code ใหม่
[System .Runtime .CompilerServices .InterpolatedStringHandler ]
public ref struct SqlHandler
{
private readonly List <object > _args ;
public SqlHandler (int literalLength , int formattedCount ) => _args = new List <object >();
public void AppendLiteral (string s ) { }
public void AppendFormatted <T >(T value ) => _args.Add (value! );
}
// parameter เป็น handler — ค่าที่แทรกจึงไม่เคยถูกต่อเป็น string
public static void Run (SqlHandler query ) { }
public static void Demo (int id ) => Run ($"select * from orders where id = { id}" ); ใช้ตอนไหน นี่คือ feature ของคนเขียน library ไม่ใช่ของคนเรียกใช้ — เราได้ประโยชน์จากมันฟรีอยู่แล้วผ่าน StringBuilder.Append และ logging API ที่เขียนด้วยกลไกนี้ · จุดที่คุ้มจะเขียนเองคือ API ที่มักถูกเรียกแล้วทิ้ง (บันทึกที่ปิดอยู่) หรือที่ต้องแยกค่าที่แทรกออกจากข้อความ (parameter ของ SQL) · ระวัง ข้อสำคัญ: handler เลือกไม่เรียก AppendFormatted ได้ แปลว่านิพจน์ที่แทรกอยู่ในช่องอาจ ไม่ถูกประเมินเลย — อย่า ใส่ code ที่มีผลข้างเคียงลงในช่องแทรกเด็ดขาด
Allow AsyncMethodBuilder attribute on methods ประสิทธิภาพ/interop เลือก builder ของ async ทีละ method ได้ # link ถาวรของ Allow AsyncMethodBuilder attribute on methods
ติด [AsyncMethodBuilder(typeof(...))] ให้ method async ทีละตัวได้แล้ว จากเดิมที่ติดได้เฉพาะกับ ชนิด ที่คืนออกไป ซึ่งเปลี่ยนไม่ได้เมื่อชนิดนั้นเป็นของ library มาตรฐาน
code เดิม
// เดิมติดแอตทริบิวต์นี้ได้ที่ชนิดเท่านั้น จึงเปลี่ยน builder ของ ValueTask
// ที่ทั้ง .NET ใช้ร่วมกันไม่ได้ ทุก method จึงต้องใช้ builder ตัวเริ่มต้นเหมือนกันหมด
public static async ValueTask <int > CountAsync ()
{
await Task.Yield ();
return 1 ;
}
code ใหม่
// เลือก builder แบบใช้ซ้ำจาก pool ให้ method นี้ตัวเดียว ไม่กระทบ method อื่น
[System .Runtime .CompilerServices .AsyncMethodBuilder (
typeof (System .Runtime .CompilerServices .PoolingAsyncValueTaskMethodBuilder <>))]
public static async ValueTask <int > CountAsync ()
{
await Task.Yield ();
return 1 ;
} ใช้ตอนไหน ใช้กับ method async ในเส้นทางร้อนที่ถูกเรียกหลายล้านครั้งและมักจบแบบ ไม่ ต้องรอจริง ซึ่งเป็นจุดที่ state machine ที่ต้องขึ้น heap แพงที่สุด · อย่า เปิดทั่วทั้ง project โดยไม่วัด: builder แบบ pool มีต้นทุนการจัดการของมันเอง และเคสส่วนใหญ่ช้าลงมากกว่าเร็วขึ้น · ระวัง กติกาของ ValueTask ที่เข้มขึ้นเมื่อใช้ pool — ค่าที่คืนออกมาต้อง await เพียงครั้งเดียวเท่านั้น เผลอ await ซ้ำหรือเก็บไว้รอทีหลัง แล้วจะได้ผลลัพธ์ของงานอื่นแบบไม่มีใครฟ้อง
C# 11 — 15 feature
Generic math support (static abstract members in interfaces) เปลี่ยนการออกแบบ interface ประกาศสมาชิก static ที่ชนิดต้องมีได้ # link ถาวรของ Generic math support (static abstract members in interfaces)
compiler รับประกันว่า: ข้อความว่า “ชนิดนี้มีตัวดำเนินการ + และค่า Zero ของตัวเอง” เขียนลงในลายเซ็นได้แล้ว และ compiler ตรวจมันที่ทุกจุดที่เรียก — ส่งชนิดที่ไม่มีเข้าไปได้ error CS0311 ตั้งแต่ compile · เดิมข้อเท็จจริงข้อนี้เขียนเป็น constraint ไม่ได้เลย ต้องเลี่ยงไป dynamic, reflection หรือเขียน method ซ้ำทีละชนิด
interface ประกาศ static abstract ได้แล้ว ทั้งตัวดำเนินการ property และ method แล้วเรียกผ่าน type parameter ที่มี constraint นั้นได้ตรง ๆ · library มาตรฐานใช้กลไกนี้ประกาศ INumber<T> ให้ทั้งตระกูลตัวเลข
code เดิม
// ตัวดำเนินการเรียกผ่าน type parameter ไม่ได้ จึงต้องเขียน method เดิมซ้ำทีละชนิด
public static decimal Sum (IEnumerable <decimal > values )
{
decimal total = 0m ;
foreach (decimal v in values) total += v;
return total;
}
public static int Sum (IEnumerable <int > values )
{
int total = 0 ;
foreach (int v in values) total += v;
return total;
}
code ใหม่
public interface ISummable <T > where T : ISummable <T >
{
static abstract T Zero { get ; }
static abstract T operator + (T left , T right );
}
// method เดียวใช้ได้กับทุกชนิดที่ประกาศไว้ว่ามีทั้งสองอย่าง
public static T Sum <T >(IEnumerable <T > values ) where T : ISummable <T >
{
T total = T.Zero;
foreach (T v in values) total = total + v;
return total;
} ใช้ตอนไหน คุ้มกับ code ที่ต้องคำนวณเหมือนกันบนหลายชนิดจริง ๆ เช่น library สถิติหรือชนิดหน่วยเงินหลายสกุลที่บวกกันเองได้ · ไม่ควร ประกาศ interface แบบนี้ให้ domain ของตัวเองเพียงเพราะดูทั่วไปดี — constraint ที่อ้างถึงตัวเอง (where T : ISummable<T>) อ่านยากขึ้นทันทีและ error ที่ได้ตอนใส่ชนิดผิดยาวมาก · ระวัง ว่ามันต้องมี runtime ที่รองรับ static abstract ด้วย ไม่ใช่แค่ยก version ภาษาอย่างเดียว
File-local types เปลี่ยนการออกแบบ ชนิดที่มองเห็นได้เฉพาะใน file ที่ประกาศ # link ถาวรของ File-local types
compiler รับประกันว่า: ขอบเขตการมองเห็นที่แคบกว่า internal มีอยู่จริงแล้ว คือ file — file อื่นในแอสเซมบลีเดียวกันที่พยายามอ้างถึงจะได้ error CS0246 และ2 file ประกาศชนิดชื่อเดียวกันพร้อมกันได้โดยไม่ชนกัน ซึ่งเดิมเป็นข้อตกลงกันเองด้วยการตั้งชื่อยาว ๆ ไม่ใช่สิ่งที่ compiler บังคับ
ตัวขยาย file หน้าการประกาศชนิด ทำให้ชนิดนั้นมองเห็นได้เฉพาะใน file ต้นฉบับ file เดียวที่ประกาศมันไว้ · ตั้งใจทำมาให้ source generator ที่ต้องปล่อยชนิดช่วยเหลือลงไปโดยไม่ไปชนกับใคร
code เดิม
// แคบที่สุดที่ประกาศได้คือ internal — ทั้งแอสเซมบลีเห็นและชื่อชนกันได้
internal sealed class OrderSqlBuilder
{
public string Build (OrderStatus status ) => "status = " + (int )status;
}
public static class OrderQueries
{
public static string Placed () => new OrderSqlBuilder ().Build (OrderStatus.Placed);
}
code ใหม่
// เห็นได้เฉพาะ file นี้ file อื่นในแอสเซมบลีเดียวกันเรียกไม่ถึงเลย
file sealed class OrderSqlBuilder
{
public string Build (OrderStatus status ) => "status = " + (int )status;
}
public static class OrderQueries
{
public static string Placed () => new OrderSqlBuilder ().Build (OrderStatus.Placed);
} ใช้ตอนไหน ที่ที่มันเกิดมาเพื่อสิ่งนี้คือ code ที่เครื่องสร้าง — ชนิดช่วยเหลือของ source generator ที่ไม่ควรมีใครนอก file นั้นเห็น · ใน code ที่คนเขียนเองใช้ได้กับตัวช่วยที่ผูกกับ file เดียวจริง ๆ · ไม่ควร ใช้ซ่อนชนิดที่ใหญ่พอจะมี test ของตัวเอง เพราะ test อยู่คนละ file จึงเรียกไม่ถึง · ระวัง ว่าชนิด file โผล่ในลายเซ็น public ไม่ได้ ถ้าเผลอคืนมันออกไปจะได้ error CS9051
Required members เปลี่ยนการออกแบบ สมาชิกที่ผู้เรียกต้องกำหนดค่าเสมอ # link ถาวรของ Required members
compiler รับประกันว่า: “ต้องมีค่าตัวนี้เสมอ” ย้ายจากคอมเมนต์และการโยน exception ตอนรัน มาเป็นข้อบังคับที่ compiler ปฏิเสธ ผู้เรียก — object initializer ที่ลืม Total ได้ error CS9035 ตั้งแต่ compile โดยที่ยังใช้ object initializer ได้ตามปกติ ไม่ต้องแลกไปเป็น constructor ที่มี parameter สิบตัว
ตัวขยาย required หน้า field หรือ property บังคับให้ทุกจุดที่สร้าง object ต้องกำหนดค่าให้สมาชิกตัวนั้นใน object initializer เว้นแต่ constructor ที่ใช้จะประกาศไว้ว่าจัดการให้แล้ว
code เดิม
public class OrderDraft
{
// บังคับได้ทางเดียวคือ constructor · ใครใช้ object initializer ก็ไม่ถูกบังคับอะไรเลย
public OrderDraft (int customerId , Money total )
{
CustomerId = customerId;
Total = total;
}
public int CustomerId { get ; }
public Money Total { get ; }
}
code ใหม่
public class OrderDraft
{
public required int CustomerId { get ; init ; }
public required Money Total { get ; init ; }
public string ? Note { get ; init ; }
}
// ลืมช่องไหนไปช่องหนึ่ง ผู้เรียก compile ไม่ผ่านทันที
public static OrderDraft Draft (int customerId ) =>
new OrderDraft { CustomerId = customerId, Total = new Money (0m , "THB" ) }; ใช้ตอนไหน ใช้กับชนิดที่สร้างด้วย object initializer เป็นหลักแต่มีช่องที่ขาดไม่ได้ เช่น DTO ของคำสั่งงานและ options ของบริการ · ไม่ควร อ่านว่ามันแทน guard ใน domain ได้ — มันบังคับแค่ว่า มีการกำหนดค่า ไม่ได้บอกว่าค่านั้นถูกต้อง ใส่ Total = new Money(-1m, "") ก็ผ่าน · ระวัง ว่ามันเป็น breaking change สำหรับผู้เรียกที่ compile อยู่แล้ว และ deserializer หรือ factory ที่สร้าง object ให้ต้องประกาศ [SetsRequiredMembers] ไม่งั้นเรียกไม่ผ่าน
ref fields and scoped ref เปลี่ยนการออกแบบ ref struct เก็บอ้างอิงไปยังหน่วยความจำของคนอื่นได้ # link ถาวรของ ref fields and scoped ref
compiler รับประกันว่า: อายุของอ้างอิงกลายเป็นสิ่งที่ compiler พิสูจน์ให้ — มันตรวจทุกเส้นทางว่า ref ที่เก็บไว้ใน field ไม่มีวันอยู่นานกว่าที่เก็บที่มันชี้ไป และปฏิเสธการ ref-assign ที่ขอบเขตแคบกว่าด้วย error CS8374 · ส่วน scoped คือการประกาศอายุที่แคบลงเอง แล้วให้ compiler ถือคำนั้นกับเรา — เดิมทั้งสองข้อเขียนได้แค่ในคอมเมนต์
ref struct ประกาศ field ที่เป็น ref ได้แล้ว คือเก็บ อ้างอิงไปยังที่เก็บของคนอื่น ไม่ใช่สำเนาของค่า · คู่กับคำว่า scoped ที่ประกาศว่าอ้างอิงตัวนี้จะไม่ถูกส่งออกไปไหน · นี่คือกลไกที่ Span<T> เคยได้รับเป็นกรณีพิเศษจาก runtime ตอนนี้เขียนเองได้
code เดิม
ไม่มี code เดิมให้เทียบ — ผลอยู่ที่ compiler ไม่ใช่ที่ไวยากรณ์
code ใหม่
public ref struct LineCursor
{
private ref readonly OrderLine _current ;
// เก็บ ref ไว้ใน field ได้ compiler ตรวจอายุของมันให้ทุกเส้นทาง
public LineCursor (ref OrderLine line ) => _current = ref line;
public Money UnitPrice => _current.UnitPrice;
}
// scoped = สัญญาว่าอ้างอิงตัวนี้จะไม่ถูกเก็บไว้ที่ไหนต่อ
public static decimal Peek (scoped ref OrderLine line ) => line.UnitPrice.Amount; ใช้ตอนไหน ใช้ใน code ที่เดินบน buffer ของคนอื่นโดยไม่คัดลอก เช่นตัวแยกข้อความ ตัวอ่าน protocol และตัวห่อ Span<T> ของตัวเอง · อย่า ลากมันเข้า code ธุรกิจ: ref struct ขึ้น heap ไม่ได้ เป็น field ของ class ไม่ได้ และข้อจำกัดจะไล่ตามไปทุกจุดที่มันไป · ระวัง ว่า compiler ตรวจอายุจากลายเซ็นเท่านั้น ถ้าเปลี่ยน parameter เป็น scoped ใน method public ที่ปล่อยไปแล้ว นั่นคือการเปลี่ยนสัญญาของ API
Raw string literals เปลี่ยนการพิมพ์ string ที่ไม่ต้อง escape อะไรเลย # link ถาวรของ Raw string literals
string ที่คร่อมด้วยเครื่องหมายคำพูดอย่างน้อยสามตัว (""") เก็บทุกอักขระตามที่พิมพ์ ทั้งเครื่องหมายคำพูดและ backslash โดยไม่ต้อง escape · ระยะเยื้องของบรรทัดปิดคือระยะที่ถูกตัดออกจากทุกบรรทัด
code เดิม
// เครื่องหมายคำพูดต้องพิมพ์ซ้ำสองตัว และเยื้องบรรทัดตาม code รอบข้างไม่ได้
// เพราะช่องว่างที่เยื้องจะติดไปเป็นเนื้อของ string ด้วย
public const string PlacedOrdersSql = @"SELECT id, total FROM "" order ""
WHERE status = 'Placed'" ;
code ใหม่
// พิมพ์ SQL ตามที่มันเป็น ไม่ต้อง escape และระยะเยื้องของบรรทัดปิดถูกตัดออกให้
public const string PlacedOrdersSql = """
SELECT id, total FROM "order"
WHERE status = 'Placed'
""" ; ใช้ตอนไหน คุ้มที่สุดกับข้อความที่มีเครื่องหมายคำพูดเป็นเนื้อในตัวเอง — SQL, JSON, regex และ HTML ที่ฝังใน test · ระวัง กฎการเยื้อง: บรรทัดปิดเป็นตัวกำหนดว่าจะตัดกี่ช่องจากทุกบรรทัด บรรทัดไหนเยื้องน้อยกว่านั้นจะได้ error CS8999 ไม่ใช่ตัดให้เท่าที่ตัดได้ · ไม่ควร ใช้กับข้อความสั้นบรรทัดเดียวที่ไม่มีอะไรต้อง escape เพราะเครื่องหมายคำพูดสามตัวอ่านหนักกว่าตัวเดียวเปล่า ๆ
Generic attributes เปลี่ยนการพิมพ์ แอตทริบิวต์ที่รับชนิดเป็น type argument # link ถาวรของ Generic attributes
ประกาศ class แอตทริบิวต์ที่เป็น generic ได้แล้ว จึงเขียน [Handles<Order>] แทน [Handles(typeof(Order))] · ตัวชนิดยังต้องเป็นชนิดที่รู้ตอน compile เหมือนเดิม type argument ที่เป็น type parameter ยังใส่ไม่ได้
code เดิม
public sealed class HandlesAttribute : Attribute
{
// ชนิดเดินทางมาเป็นค่า Type ไม่ใช่เป็น type argument
public HandlesAttribute (Type messageType ) => MessageType = messageType;
public Type MessageType { get ; }
}
[Handles (typeof (Order ))]
public void Save (Order order ) { }
code ใหม่
public sealed class HandlesAttribute <TMessage > : Attribute
{
public Type MessageType => typeof (TMessage );
}
[Handles <Order >]
public void Save (Order order ) { } ใช้ตอนไหน อ่านง่ายขึ้นจริงกับแอตทริบิวต์ที่พูดถึง 'ชนิดไหน' เป็นหลัก เช่นการแม็ป handler กับ message · ไม่ควร ไล่แก้แอตทริบิวต์เดิมทั้ง project ให้เป็น generic เพราะ code ที่อ่านแอตทริบิวต์ด้วย reflection ต้องแก้ตามทั้งหมด (ชื่อชนิดที่มันมองหาเปลี่ยนไป) แลกกับ diff ที่ไม่เปลี่ยนความหมายอะไรเลย · ระวัง ว่าชนิดบางกลุ่ม เช่น dynamic และ tuple ที่มีชื่อช่อง ใส่เป็น type argument ของแอตทริบิวต์ไม่ได้
Newlines in string interpolation expressions เปลี่ยนการพิมพ์ นิพจน์ในวงเล็บปีกกาขึ้นบรรทัดใหม่ได้ # link ถาวรของ Newlines in string interpolation expressions
นิพจน์ที่อยู่ในวงเล็บปีกกาของ string แบบ $"..." ขึ้นบรรทัดใหม่ได้แล้ว จึงวาง switch หรือนิพจน์ยาว ๆ ลงไปตรงนั้นได้โดยไม่ต้องบีบให้อยู่บรรทัดเดียว
code เดิม
public static string Label (Order order )
{
// นิพจน์ในวงเล็บปีกกาขึ้นบรรทัดใหม่ไม่ได้ ต้องยกออกมาพักไว้ในตัวแปรก่อน
string text = order.Status switch
{
OrderStatus .Placed => "รอร้านตอบรับ" ,
OrderStatus .Delivered => "ส่งถึงลูกค้าแล้ว" ,
_ => "อื่น ๆ" ,
};
return $"สถานะ: { text}" ;
}
code ใหม่
public static string Label (Order order ) => $"สถานะ: { order. Status switch
{
OrderStatus . Placed => "รอร้านตอบรับ",
OrderStatus . Delivered => "ส่งถึงลูกค้าแล้ว",
_ => "อื่น ๆ",
}}" ; ใช้ตอนไหน ช่วยได้จริงตอน string ต้องเลือกคำตามค่าหนึ่งค่า และการยกออกไปเป็นตัวแปรทำให้ต้องตั้งชื่อที่ไม่มีความหมาย · ไม่ควร ใช้กับตรรกะที่ยาวกว่าสองสามกิ่ง เพราะ code ที่ตัดสินใจไปซ่อนอยู่กลางข้อความ คนอ่านและเครื่องมือ diff หาไม่เจอ · ระวัง ว่า string ที่ยาวหลายบรรทัดแบบนี้ทำให้บรรทัดของ error ชี้ไปไกลจากจุดที่คนคิดว่าผิด
List patterns เปลี่ยนการพิมพ์ จับคู่ลำดับด้วยรูปร่างของมัน # link ถาวรของ List patterns
pattern [a, b, c] จับคู่ลำดับที่มีความยาวและสมาชิกตามที่เขียน และ .. คือช่วงที่เหลือ (slice pattern) ใช้ได้กับอาร์เรย์ List<T> และชนิดที่มี Length/Count กับตัวห้อยครบตามรูปแบบ
code เดิม
public static string Shape (int [] ids )
{
if (ids.Length == 0 ) return "ไม่มีรายการ" ;
if (ids.Length == 1 ) return $"รายการเดียว: { ids[ 0 ]}" ;
return $"{ ids[ 0 ]} ถึง { ids[ ids. Length - 1 ]}" ;
}
code ใหม่
public static string Shape (int [] ids ) => ids switch
{
[] => "ไม่มีรายการ" ,
[var only ] => $"รายการเดียว: { only}" ,
[var first , .. , var last ] => $"{ first} ถึง { last}" ,
}; ใช้ตอนไหน อ่านง่ายขึ้นชัดเจนกับข้อมูลที่ 'จำนวนชิ้น' คือส่วนหนึ่งของกฎ เช่นการแยกคำสั่งที่ผู้ใช้พิมพ์เข้ามา หรือแถวของ file CSV · ระวัง ต้นทุน: pattern ที่มี .. บนชนิดที่ไม่ใช่อาร์เรย์หรือ Span<T> จะเรียกตัวห้อยแบบช่วงซึ่ง คัดลอก ข้อมูลออกมา · ไม่ควร วางแผนใช้มันกับ IEnumerable<T> เพราะ pattern นี้ต้องมี Count/Length ให้ดูก่อน ถ้าไม่มีคือ error CS8985 ไม่ใช่การไล่วนซ้ำให้อัตโนมัติ
Auto-default structs เปลี่ยนการพิมพ์ constructor ของ struct ไม่ต้องเขียนค่าให้ครบทุก field # link ถาวรของ Auto-default structs
constructor ของ struct ที่ไม่ได้กำหนดค่าให้ field ครบทุกตัว compile ผ่านแล้ว compiler เติมค่าเริ่มต้น (ศูนย์ทั้งก้อน) ให้ field ที่เหลือเอง แทนที่จะปฏิเสธด้วย error CS0171
code เดิม
public struct CartTotals
{
// ต้องเขียนให้ครบทุก field เอง ขาดตัวใดตัวหนึ่งคือ compile ไม่ผ่าน
public CartTotals (decimal net )
{
Net = net;
Vat = 0m ;
}
public decimal Net ;
public decimal Vat ;
}
code ใหม่
public struct CartTotals
{
// เขียนแค่ field ที่สนใจ ที่เหลือ compiler เซ็ตศูนย์ให้ — และไม่เตือนอะไรเลย
public CartTotals (decimal net ) => Net = net;
public decimal Net ;
public decimal Vat ;
} ใช้ตอนไหน ตัด code กำหนดค่าศูนย์ที่ไม่มีความหมายทิ้งได้ในชนิดที่ 'ศูนย์' เป็นค่าเริ่มต้นที่ถูกอยู่แล้ว · ระวัง ให้มาก: มันคือการที่ compiler เลิก พิสูจน์ว่าเราเขียนค่าครบ ไม่ใช่พิสูจน์เพิ่ม และไม่มี warning สักตัวบอกว่ามี field ไหนถูกเซ็ตให้เงียบ ๆ · ไม่ควร พึ่งมันในชนิดที่ศูนย์เป็นค่าที่ผิด เช่นอัตราแลกเปลี่ยนหรือตัวคูณ กรณีนั้นการเขียนให้ครบเองคือสิ่งที่ทำให้อ่านแล้วรู้ว่าตั้งใจ
Pattern match Span<char> on a constant string เปลี่ยนการพิมพ์ จับคู่ Span<char> กับ string คงที่ได้ตรง ๆ # link ถาวรของ Pattern match Span<char> on a constant string
ReadOnlySpan<char> และ Span<char> จับคู่กับค่าคงที่ที่เป็น string ใน pattern ได้แล้ว จึงเขียน span is "Placed" หรือใส่ลงใน switch ได้เหมือน string ธรรมดา
code เดิม
public static string Describe (ReadOnlySpan <char > status )
{
// เทียบเนื้อในของ span ได้อยู่แล้วและไม่ alloc — แค่เขียนเป็น pattern ไม่ได้
if (status.SequenceEqual ("Placed" )) return "รอร้านตอบรับ" ;
if (status.SequenceEqual ("Delivered" )) return "ส่งถึงลูกค้าแล้ว" ;
return "อื่น ๆ" ;
}
code ใหม่
public static string Describe (ReadOnlySpan <char > status ) => status switch
{
"Placed" => "รอร้านตอบรับ" ,
"Delivered" => "ส่งถึงลูกค้าแล้ว" ,
_ => "อื่น ๆ" ,
}; ใช้ตอนไหน ใช้ตอนแยกคำจาก buffer ที่ตัดมาเป็นช่วง ๆ แล้วต้องตัดสินใจตามเนื้อคำ โดยไม่อยากสร้าง string ขึ้นมาเพียงเพื่อเทียบ · ระวัง อย่าอ่านว่ามันประหยัดหน่วยความจำให้: SequenceEqual ก็ไม่ alloc อยู่แล้ว สิ่งที่ได้เพิ่มคือรูปประโยค ไม่ใช่ต้นทุนที่หายไป · ไม่ควร ใช้กับการเทียบที่ต้องไม่สนตัวพิมพ์เล็กใหญ่หรือขึ้นกับวัฒนธรรม เพราะ pattern เทียบแบบ ordinal เท่านั้น
Extended nameof scope เปลี่ยนการพิมพ์ nameof มองเห็น parameter ของ method ที่แอตทริบิวต์เกาะอยู่ # link ถาวรของ Extended nameof scope
ในแอตทริบิวต์ที่เกาะอยู่กับ method parameter ของ method นั้นอยู่ในขอบเขตของ nameof แล้ว จึงเขียน nameof(order) ได้แทนที่จะพิมพ์ชื่อเป็น string · แอตทริบิวต์อย่าง [NotNullWhen] และ [CallerArgumentExpression] คือกลุ่มที่ได้ประโยชน์ตรง ๆ
code เดิม
public class AuditAttribute : Attribute
{
public AuditAttribute (string parameterName ) => ParameterName = parameterName;
public string ParameterName { get ; }
}
// ชื่อ parameter เป็น string ล้วน เปลี่ยนชื่อเมื่อไรค่านี้ค้างอยู่แบบนั้นเงียบ ๆ
[Audit ("order" )]
public static void Cancel (Order order ) { }
code ใหม่
public class AuditAttribute : Attribute
{
public AuditAttribute (string parameterName ) => ParameterName = parameterName;
public string ParameterName { get ; }
}
// nameof มองเห็น order ที่เป็น parameter ของ method ที่แอตทริบิวต์นี้เกาะอยู่
[Audit (nameof (order))]
public static void Cancel (Order order ) { } ใช้ตอนไหน ใช้กับแอตทริบิวต์ที่ต้องอ้างชื่อ parameter เพื่อให้การเปลี่ยนชื่อ parameter ลากค่าในแอตทริบิวต์ไปด้วย · ระวัง ข้อสำคัญของรายการนี้: มันไม่ได้ถูกคุมด้วยสวิตช์ version ภาษา snippet นี้ compile ผ่านที่ version ก่อนหน้าด้วย compiler รุ่นนี้ สิ่งที่ตัดสินคือรุ่นของ SDK ที่ใช้ ไม่ใช่ตัวเลขใน <LangVersion> · ไม่ควร อ่านว่ามันทำให้ compiler ตรวจอะไรเพิ่ม nameof คืน string เหมือนเดิมทุกประการ
nint and nuint alias System.IntPtr and System.UIntPtr เปลี่ยนการพิมพ์ nint กับ nuint กลายเป็นชื่อเรียกของ IntPtr และ UIntPtr # link ถาวรของ nint and nuint alias System.IntPtr and System.UIntPtr
nint และ nuint ไม่ใช่ชนิดแยกที่ compiler เสกขึ้นมาอีกต่อไป แต่เป็นชื่อเรียกของ System.IntPtr และ System.UIntPtr ตรง ๆ เหมือนที่ int เป็นชื่อเรียกของ System.Int32 · ทั้งคู่จึงมีสมาชิก ตัวดำเนินการ และการแปลงชุดเดียวกันทุกประการ
code เดิม
ไม่มี code เดิมให้เทียบ — ผลอยู่ที่ compiler ไม่ใช่ที่ไวยากรณ์
code ใหม่
// สองชื่อนี้คือชนิดเดียวกัน จะประกาศ Next(nint) คู่กับ Next(IntPtr) ไม่ได้ (CS0111)
public static nint Next (IntPtr handle ) => handle + 1 ;
// สมาชิกของ IntPtr เรียกผ่านชื่อ nuint ได้ เพราะเป็นชนิดเดียวกัน
public static nuint Bytes (nuint count ) => count * (nuint )IntPtr.Size; ใช้ตอนไหน ประโยชน์คือเลิกต้องคิดว่าสองชื่อนี้ต่างกันตรงไหนตอนอ่าน code interop · ระวัง ข้อที่บทความนี้ต้องพูดตรง ๆ: รายการนี้ไม่ใช่สวิตช์ที่กดได้ การรวมสองชื่อผูกอยู่กับ RuntimeFeature.NumericIntPtr ของ target framework ไม่ใช่กับ <LangVersion> — บน .NET รุ่นใหม่มันเป็นจริงอยู่แล้วไม่ว่าจะตั้ง version ภาษาไว้เท่าไร snippet นี้จึง compile ผ่านทั้ง2 version · ไม่ควร เอา nint มาใช้แทน int/long ใน code ธุรกิจ ความกว้างของมันเปลี่ยนตาม platform test ที่ผ่านบนเครื่องพัฒนาจึงไม่การันตีเป้าหมายจริง
Warning wave 7 เปลี่ยนการพิมพ์ คำเตือนชุดใหม่เรื่องชื่อชนิดที่เป็นตัวพิมพ์เล็กล้วน # link ถาวรของ Warning wave 7
คลื่นคำเตือนชุดที่เจ็ดเพิ่ม CS8981 ซึ่งเตือนว่าชื่อชนิดที่เป็นอักษร ASCII ตัวพิมพ์เล็กล้วนอาจถูกจองเป็นคำสงวนของภาษาในอนาคต · เป็นคำเตือน ไม่ใช่ไวยากรณ์ใหม่ code ยัง compile และรันได้ตามปกติ
code เดิม
ไม่มี code เดิมให้เทียบ — ผลอยู่ที่ compiler ไม่ใช่ที่ไวยากรณ์
code ใหม่
// ชื่อที่เป็นตัวพิมพ์เล็ก ASCII ล้วนได้ warning CS8981 — ไม่ใช่ error
// เปลี่ยนเป็น Order เมื่อไรคำเตือนก็หายไป โดยพฤติกรรมของโปรแกรมไม่ต่างกันเลย
public class order
{
public int Id { get ; set ; }
} ใช้ตอนไหน อ่านมันเป็นสัญญาณเตือนล่วงหน้าว่าชื่อนั้นอาจชนกับคำสงวนในอนาคต แล้วเปลี่ยนชื่อเสียตั้งแต่ตอนนี้ · ระวัง ว่าคำเตือนของคลื่นใหม่ผูกกับ ระดับคำเตือน (<AnalysisLevel>/-warn:) ไม่ใช่กับ <LangVersion> — ตั้ง version ภาษาย้อนหลังก็ยังเห็นมัน และทีมที่เปิด TreatWarningsAsErrors จะเจอ build แดงทันทีที่ยกระดับ ทั้งที่ไม่ได้แตะ code สักบรรทัด · ไม่ควร ปิดมันทิ้งทั้ง solution เพียงเพราะมันดังหลายจุดพร้อมกัน — จุดที่มันดังคือจุดที่ชื่อจะกลายเป็นปัญหาจริง
UTF-8 string literals ประสิทธิภาพ/interop string ที่เก็บเป็น byte UTF-8 ตั้งแต่ตอน compile # link ถาวรของ UTF-8 string literals
ต่อท้าย string ด้วย u8 แล้วได้ ReadOnlySpan<byte> ที่เป็น byte UTF-8 ซึ่ง compiler เข้ารหัสไว้ในแอสเซมบลีตั้งแต่ตอน compile ไม่ใช่ string ที่ต้องแปลงตอนรัน
code เดิม
// เข้ารหัสใหม่ทุกครั้งที่เรียก และได้อาร์เรย์ก้อนใหม่บน heap ทุกครั้ง
public static byte [] StatusHeader () =>
System.Text.Encoding.UTF8.GetBytes ("order-status: " );
code ใหม่
// byte ถูกฝังไว้ในแอสเซมบลีแล้ว ตอนรันไม่มีทั้งการแปลงและการจัดสรร
public static ReadOnlySpan<byte> StatusHeader => "order-status: " u8; ใช้ตอนไหน คุ้มกับค่าคงที่ที่ปลายทางเป็น byte อยู่แล้ว เช่นหัวข้อของ protocol ตัวคั่นของ JSON และ key ที่ส่งเข้า API ที่รับ ReadOnlySpan<byte> · อย่า ใช้กับข้อความที่คนอ่าน เพราะมันไม่ใช่ string จะเอาไปต่อ จัดรูปแบบ หรือส่งเข้า API ที่รับ string ไม่ได้ · ระวัง ว่าผลลัพธ์เป็น ReadOnlySpan<byte> ซึ่งเก็บลง field ของ class ไม่ได้ ถ้าต้องเก็บไว้จริงต้องเรียก .ToArray() ซึ่งย้อนกลับไปจัดสรรเหมือนเดิม
Improved method group conversion to delegate ประสิทธิภาพ/interop delegate ที่แปลงจากชื่อ method ถูก cache ไว้ใช้ซ้ำ # link ถาวรของ Improved method group conversion to delegate
เมื่อแปลงกลุ่ม method (เขียนชื่อ method เปล่า ๆ ตรงที่ต้องการ delegate) compiler เก็บ object delegate ที่สร้างไว้ไปใช้ซ้ำได้แล้ว แทนที่จะสร้างใหม่ทุกครั้งที่แปลง · code ที่พิมพ์เหมือนเดิมทุกตัวอักษร แต่ IL ที่ได้ต่างกัน
code เดิม
private static int GetId (Order order ) => order.Id;
// ต้อง cache เอง ไม่งั้นทุกครั้งที่แปลงได้ object delegate ตัวใหม่
private static readonly Func <Order , int > _selector = GetId;
public static Func <Order , int > Selector () => _selector;
code ใหม่
private static int GetId (Order order ) => order.Id;
// compiler cache ให้เอง — เรียกสองครั้งได้ object ตัวเดิม
// (ReferenceEquals(Selector(), Selector()) เป็น true ที่ C# 11 และ false ที่ version ก่อนหน้า)
public static Func <Order , int > Selector () => GetId; ใช้ตอนไหน ได้มาฟรีตอนอัปเกรด และช่วยจริงในเส้นทางที่ส่งชื่อ method เป็น callback ใน loop เช่น Select(GetId) ที่ถูกเรียกซ้ำ ๆ · ระวัง ว่ามันเปลี่ยนตัวตนของ object: code ที่เอา delegate ไปเป็น key ของ dictionary ถอด event handler ด้วยตัวที่แปลงใหม่ หรือเทียบด้วย ReferenceEquals จะได้ผลไม่เหมือนเดิม · ไม่ควร ใช้เป็นเหตุผลไล่ลบ field ที่ cache delegate ไว้เอง ถ้าจุดนั้นต้องการตัวตนที่คงที่อย่างเป็นทางการ ให้เขียนไว้ให้ชัดต่อไป
C# 12 — 7 feature
Primary constructors เปลี่ยนการพิมพ์ เขียน parameter ของคอนสตรักเตอร์ไว้ที่หัว class ได้ # link ถาวรของ Primary constructors
เขียน class OrderPricer(decimal taxRate) แล้วใช้ taxRate ได้จากทุกสมาชิกใน class · compiler เก็บมันไว้ใน field ที่สังเคราะห์ให้ ใช้ได้กับ class และ struct ทุกตัว
code เดิม
public class OrderPricer
{
private readonly decimal _taxRate ;
public OrderPricer (decimal taxRate ) => _taxRate = taxRate;
public Money WithTax (Money price ) =>
new Money (price.Amount * (1m + _taxRate), price.Currency);
}
code ใหม่
public class OrderPricer (decimal taxRate )
{
// ไม่มี field ให้ประกาศ ไม่มีคอนสตรักเตอร์ให้เขียน — แต่ก็ไม่มี readonly ด้วย
public Money WithTax (Money price ) =>
new Money (price.Amount * (1m + taxRate), price.Currency);
} ใช้ตอนไหน คุ้มที่สุดกับ class ที่รับ dependency ทางคอนสตรักเตอร์แล้วเก็บใส่ field เฉย ๆ อย่าง service และ handler ซึ่งเดิมต้องพิมพ์ชื่อเดียวกันสามรอบ · ระวัง ข้อที่คนเข้าใจผิดบ่อยที่สุด: parameter ของ primary constructor บน class ไม่ใช่ field readonly — method ใน class เดียวกันเขียนทับมันได้ และ compiler ไม่ฟ้อง ถ้าต้องการของที่แช่แข็งจริงยังต้องประกาศ field readonly เองแล้วรับค่ามาใส่ · อย่า ใช้กับ class ที่ต้องตรวจ invariant ตอนสร้าง เพราะไม่มีที่ให้วางการตรวจ ต้องเขียนคอนสตรักเตอร์ปกติอยู่ดี
Collection expressions เปลี่ยนการพิมพ์ สร้าง collection ด้วยวงเล็บเหลี่ยมชุดเดียว # link ถาวรของ Collection expressions
[a, b, c] สร้าง collection โดยให้ชนิดปลายทางเป็นตัวบอกว่าจะสร้างอะไร และ ..other (spread element) แผ่สมาชิกของ collection อื่นเข้ามาในตัวสร้างเดียวกัน
code เดิม
public static int [] AllIds (Order order , int [] extra )
{
int [] head = new int [] { order.Id };
// ต่อสองก้อนเข้าด้วยกันแล้วต้องแปลงกลับเป็นอาร์เรย์อีกรอบ
return head.Concat (extra).Append (99 ).ToArray ();
}
code ใหม่
public static int [] AllIds (Order order , int [] extra )
{
int [] head = [order.Id];
// จุดสองจุด (..) แผ่สมาชิกของ collection อื่นเข้ามาในวงเล็บเดียวกัน
return [.. head, .. extra, 99 ];
} ใช้ตอนไหน ใช้ได้เป็นค่าเริ่มต้นแทน new T[] { ... } ทุกจุดที่ชนิดปลายทางชัดอยู่แล้ว และคุ้มมากตอนต้องรวมหลายก้อนเข้าด้วยกัน · ระวัง ว่า compiler เป็นคนเลือกวิธีสร้างให้ ไม่ใช่เรา — ก้อนเดียวกันอาจกลายเป็นอาร์เรย์บน heap หรือ buffer บนสแต็กก็ได้ ขึ้นกับชนิดปลายทาง จึงอย่าอ่านมันเป็นคำสัญญาเรื่องต้นทุน · ไม่ควร ใช้ในที่ที่ชนิดปลายทางไม่ชัด เพราะ var x = [1, 2]; compile ไม่ผ่าน วงเล็บเหลี่ยมยังไม่มีชนิดของตัวเอง
Optional parameters in lambda expressions เปลี่ยนการพิมพ์ lambda มีค่าเริ่มต้นของ parameter ได้ # link ถาวรของ Optional parameters in lambda expressions
เขียนค่าเริ่มต้นให้ parameter ของ lambda ได้แล้ว เช่น (decimal a, decimal rate = 0.07m) => ... · compiler สังเคราะห์ชนิด delegate ที่พกค่าเริ่มต้นนั้นไว้ให้
code เดิม
public static decimal Quote (decimal amount )
{
// ค่าเริ่มต้นเขียนได้เฉพาะกับ local function ไม่ใช่กับ lambda
decimal WithTax (decimal a , decimal rate = 0.07m ) => a * (1m + rate);
return WithTax (amount);
}
code ใหม่
public static decimal Quote (decimal amount )
{
var withTax = (decimal a , decimal rate = 0.07m ) => a * (1m + rate);
return withTax (amount);
} ใช้ตอนไหน คุ้มกับ lambda ที่ถูกเก็บไว้เป็นตัวแปรแล้วเรียกหลายจุดด้วย argument ไม่ครบ เช่นตัวตั้งค่าเริ่มต้นของ API ที่รับ callback · ระวัง ว่าค่าเริ่มต้นติดอยู่กับ ชนิดที่ compiler สังเคราะห์ให้ ไม่ใช่กับตัว lambda ถ้าเอาไปใส่ Func<decimal, decimal, decimal> ที่ประกาศเอง ค่าเริ่มต้นจะไม่ตามไปด้วย ผู้เรียกต้องส่งครบทุกตัว (compiler เตือน CS9099 ให้ก่อน แล้วจุดเรียกที่ส่งไม่ครบจะพังเป็น CS7036) · ไม่ควร ใช้แทน local function ที่ทำงานเดียวกัน เพราะ local function อ่านง่ายกว่าและไม่ต้องจัดสรร delegate
ref readonly parameters เปลี่ยนการพิมพ์ parameter ที่ส่งด้วย ref แต่เขียนทับไม่ได้ # link ถาวรของ ref readonly parameters
ref readonly คือ parameter ที่ส่งด้วยการอ้างอิงและเขียนทับไม่ได้ เหมือน in ทุกอย่าง ต่างกันที่จุดเรียก: มันเตือนเมื่อผู้เรียกส่งค่าที่ไม่ใช่ตัวแปรเข้ามา ส่วน in รับไปเงียบ ๆ
code เดิม
// 'in' ห้ามเขียนทับได้อยู่แล้ว แต่ Total(new Money(...)) ผ่านแบบไม่มีเสียงเตือน
// ทั้งที่ compiler ต้องแอบสร้างตัวแปรชั่วคราวให้ก่อนส่ง
public static decimal Amount (in Money money ) => money.Amount;
public static decimal Call () => Amount (new Money (100m , "THB" ));
code ใหม่
public static decimal Amount (ref readonly Money money) => money.Amount;
public static decimal Call ()
{
// ส่งค่าชั่วคราวเข้ามา ได้ warning CS9193 · ลืมเขียน in ได้ CS9192
Money money = new Money (100m , "THB" );
return Amount (in money);
} ใช้ตอนไหน ใช้กับ API ที่ ต้อง อ่านตัวแปรของผู้เรียกตัวจริง เช่น function interop ที่เอาที่อยู่ของ struct ไปใช้ต่อ การได้ค่าชั่วคราวคือ bug · ไม่ควร ไล่เปลี่ยน in ที่มีอยู่แล้วเป็น ref readonly ทั้ง codebase: การรับประกันว่าห้ามเขียนทับเป็นของเดิมที่ in ให้มาอยู่แล้ว สิ่งเดียวที่ได้เพิ่มคือคำเตือนหนึ่งตัว แลกกับการที่ผู้เรียกทุกจุดต้องพิมพ์ in เอง · ระวัง กับ struct ก้อนใหญ่ที่อ่านบ่อย ๆ ผลด้านความเร็วเท่ากับ in เป๊ะ ไม่ได้เร็วขึ้นเพราะเปลี่ยนคำ
Alias any type เปลี่ยนการพิมพ์ ตั้งชื่อย่อให้ชนิดอะไรก็ได้ ไม่ใช่แค่ชนิดที่มีชื่อ # link ถาวรของ Alias any type
using alias ตั้งชื่อให้ tuple อาร์เรย์ pointer และชนิดที่ไม่มีชื่ออื่น ๆ ได้แล้ว จากเดิมที่ตั้งได้เฉพาะชนิดที่มีชื่ออยู่แล้ว
code เดิม
// alias เดิมรับได้เฉพาะชนิดที่มีชื่อ tuple จึงต้องพิมพ์เต็มทุกจุดที่ใช้
public static class Delivery
{
public static decimal Lat ((decimal Lat , decimal Lng ) point ) => point.Lat;
public static decimal Lng ((decimal Lat , decimal Lng ) point ) => point.Lng;
}
code ใหม่
using Coord = (decimal Lat , decimal Lng );
public static class Delivery
{
public static decimal Lat (Coord point ) => point.Lat;
public static decimal Lng (Coord point ) => point.Lng;
} ใช้ตอนไหน คุ้มกับ tuple รูปเดิมที่โผล่ซ้ำในหลายลายเซ็นภายใน project เดียว เพราะแก้รูปร่างที่เดียวจบ · ระวัง ข้อที่ผู้อ่านส่วนใหญ่จะเดาผิด: alias ไม่ใช่ชนิดใหม่ มันคือชื่อเล่นที่หายไปตอน compile · ส่ง Coord เข้า parameter ที่ประกาศเป็น tuple เปล่า (และกลับกัน) ได้โดยไม่มีอะไรฟ้อง · อย่า ใช้มันหวังกันไม่ให้ละติจูดถูกส่งไปในช่องลองจิจูด ถ้าต้องการแบบนั้นต้องประกาศชนิดของตัวเองจริง ๆ · และ alias มีผลแค่ใน file เดียว เว้นแต่จะเขียนเป็น global using
Experimental attribute เปลี่ยนการพิมพ์ ทำเครื่องหมายว่า API นี้ยังเป็นของทดลอง # link ถาวรของ Experimental attribute
ติด [Experimental("KAEN0001")] ให้ชนิดหรือสมาชิก แล้วทุกจุดที่เรียกใช้จะ compile ไม่ผ่านจนกว่าจะปิดเสียงรหัสนั้นอย่างจงใจ · ตัวแอตทริบิวต์ไม่ใช่ไวยากรณ์ใหม่ จึงไม่ได้ถูกคุมด้วย -langversion แต่ผูกกับ version ของ compiler และ library มาตรฐานแทน
code เดิม
// เดิมต้องยืม [Obsolete] มาพูดแทน ทั้งที่ความหมายคือ "ยังไม่นิ่ง" ไม่ใช่ "เลิกใช้แล้ว"
// และปิดเสียงเฉพาะตัวไม่ได้ เพราะของทุกตัวใน project ใช้รหัสเดียวกันหมด
[Obsolete ("ยังเป็นของทดลอง สัญญาเปลี่ยนได้" , error: true )]
public class DynamicPricer
{
public Money Quote (Money price ) => price;
}
code ใหม่
// รหัสในวงเล็บเป็นของเราเอง ผู้เรียกจึงปิดเสียงได้ทีละ API ไม่ใช่ทั้ง project
[System .Diagnostics .CodeAnalysis .Experimental ("KAEN0001" )]
public class DynamicPricer
{
public Money Quote (Money price ) => price;
} ใช้ตอนไหน ใช้กับ API สาธารณะของ library ที่ปล่อยออกไปให้ลองก่อน แล้วยังตั้งใจจะเปลี่ยนสัญญา · ระวัง ว่ามันคือ error ไม่ใช่ warning ผู้เรียกต้องเขียนการปิดเสียงลงไปใน code หรือ file project จริง ๆ ถึงจะสร้างผ่าน · อย่า ติดกับ API ที่ลูกค้าใช้ในโปรดักชันอยู่แล้ว เพราะเป็นการทำให้ build ของเขาพังในวันที่อัปเกรด · และมันไม่ได้ทำให้ทำอะไรใหม่ได้: [Obsolete(error: true)] บังคับเรื่องเดียวกันมาตั้งแต่ภาษา version แรก (ต่างกันแค่ได้ CS0619 ที่ปิดเสียงแยกตัวไม่ได้) สิ่งที่เพิ่มขึ้นคือความละเอียดของรหัส ไม่ใช่การบังคับชนิดใหม่
Inline arrays ประสิทธิภาพ/interop อาร์เรย์ขนาดคงที่ที่ฝังอยู่ในตัว struct # link ถาวรของ Inline arrays
ติด [InlineArray(n)] ให้ struct ที่มี field เดียว แล้ว compiler กาง field นั้นเป็น n ช่องต่อเนื่องกันในตัว struct เอง ใช้ดัชนีและ foreach ได้เหมือนอาร์เรย์ โดยไม่ขึ้น heap และไม่ต้องอยู่ใน unsafe
code เดิม
public unsafe struct Slots
{
// fixed buffer ตัวเดิม: ต้องเปิด unsafe ทั้ง project
// และรับได้เฉพาะชนิดพื้นฐานอย่าง int/byte เท่านั้น
public fixed int Values [4 ];
}
public static unsafe int First (ref Slots slots ) => slots.Values[0 ];
code ใหม่
[System .Runtime .CompilerServices .InlineArray (4 )]
public struct Slots
{
// ประกาศช่องเดียว compiler กางให้เป็นสี่ช่องต่อเนื่อง
private int _element0 ;
}
public static int First (Slots slots ) => slots[0 ]; ใช้ตอนไหน ใช้กับ buffer ขนาดคงที่เล็ก ๆ ในเส้นทางร้อน และในโครงสร้างที่ต้องวางตัวตรงกับ layout ของฝั่ง native · เป็นทางเดียวที่ได้ buffer ฝังตัวโดยไม่ต้องใช้ unsafe และรองรับชนิดอ้างอิงด้วย · อย่า ใช้แทน List<T> หรืออาร์เรย์ใน code ธุรกิจ: ขนาดตายตัวตั้งแต่ compile ขยายไม่ได้ และเพราะมันเป็น struct การส่งต่อแต่ละครั้งคือการคัดลอกทั้งก้อน ยิ่ง n ใหญ่ยิ่งแพงกว่าส่ง reference · ระวัง ว่ามันเกิดมาเพื่อให้ library มาตรฐานใช้ ไม่ใช่ไวยากรณ์ที่ตั้งใจให้โผล่ใน code app ทั่วไป
C# 13 — 10 feature
allows ref struct constraint เปลี่ยนการออกแบบ ข้อจำกัดที่ประกาศว่า T เป็นชนิดบนสแต็กได้ # link ถาวรของ allows ref struct constraint
compiler รับประกันว่า: ลายเซ็นพูดเองได้แล้วว่า “algorithm ตัวนี้ปลอดภัยที่จะเรียกด้วยชนิดที่อยู่ได้เฉพาะบนสแต็ก” และ compiler ถือทั้งสองฝั่งไว้กับคำนั้น — ในตัว method มันบังคับกฎ ref-safety กับ T ทุกจุด (box ไม่ได้ เก็บลง field ไม่ได้ ส่งต่อเข้า generic ที่ไม่ได้ประกาศข้อเดียวกันไม่ได้) ผู้เรียกจึงได้สิทธิ์ส่ง Span เข้าไปโดยที่ความปลอดภัยข้อนั้นถูก ตรวจแล้ว ไม่ใช่ถูกสัญญาไว้ในคอมเมนต์
เขียน where T : allows ref struct ต่อท้ายข้อจำกัดของ type parameter ได้ แปลว่า method หรือชนิดนั้นยอมรับ ref struct อย่าง Span<T>/ReadOnlySpan<T> เป็น T ได้ ซึ่งก่อนหน้านี้ generic ทุกตัวห้ามไว้หมดไม่มีข้อยกเว้น
code เดิม
// T ห้ามเป็น ref struct ทุกกรณี generic ตัวนี้จึงรับ span ไม่ได้เลย
public static T Pick <T >(T candidate , T fallback , bool ok ) => ok ? candidate : fallback;
// ต้องเขียน version เฉพาะสำหรับ span ซ้ำอีกตัว ทั้งที่ code ข้างในเหมือนกันทุกบรรทัด
public static ReadOnlySpan <char > PickSpan (
ReadOnlySpan <char > candidate , ReadOnlySpan <char > fallback , bool ok ) =>
ok ? candidate : fallback;
public static ReadOnlySpan <char > Currency (Money money , bool known ) =>
PickSpan (money.Currency.AsSpan (), "THB" .AsSpan (), known);
code ใหม่
// allows ref struct = ลายเซ็นเปิดให้ T เป็นชนิดที่อยู่ได้เฉพาะบนสแต็ก
// ราคาที่จ่ายคือ compiler บังคับกฎ ref-safety กับ T ทั้งตัว method — เขียน
// object boxed = candidate; ลงไปในนี้จะได้ CS0029 ทันที
public static T Pick <T >(T candidate , T fallback , bool ok ) where T : allows ref struct
=> ok ? candidate : fallback;
public static ReadOnlySpan <char > Currency (Money money , bool known ) =>
Pick (money.Currency.AsSpan (), "THB" .AsSpan (), known); ใช้ตอนไหน ใช้กับ generic helper ที่อยากให้ทำงานได้ทั้งกับชนิดปกติและกับ buffer บนสแต็ก โดยไม่ต้องคัดลอก code เดิมทิ้งไว้สองชุด · ไม่ควร ติดข้อนี้ไว้ทุกที่เพราะดูเหมือนของฟรี มันคือการ ลดสิทธิ์ ของตัว method เอง พอติดแล้ว code ข้างในจะ box T, เก็บ T ลง field หรือส่ง T ต่อเข้า generic ตัวอื่นที่ไม่ได้ประกาศข้อเดียวกันไม่ได้อีกเลย · ระวัง อย่าอ่านมันว่า “ตอนนี้ span ไปได้ทุกที่แล้ว” — ข้อห้ามเรื่องคร่อม await เป็นคนละเรื่องและยังอยู่ครบ
New escape sequence - \e เปลี่ยนการพิมพ์ ลำดับหนี \e สำหรับตัวอักษร ESC # link ถาวรของ New escape sequence - \e
เขียน \e ใน string หรือ char literal เพื่อหมายถึงตัวอักษร ESCAPE (U+001B) ได้แล้ว ให้ตัวอักษรเดียวกันกับ \u001b ทุกประการ ต่างกันแค่จำนวนตัวอักษรที่พิมพ์
code เดิม
// ต้องสะกดรหัส Unicode เต็มหกตัวอักษรเอง เพราะรูปย่อ \x1b กลืนเลขฐานสิบหก
// ที่ตามมาข้างหลัง ("\x1bAB" จึงไม่ใช่ ESC ตามด้วย AB อย่างที่ตาเห็น)
public const string Reset = " \u001b [0m" ;
public static string Bold (string text ) => $" \u001b [1m{ text}{ Reset}" ;
code ใหม่
// \e คือ ESC (U+001B) ตัวเดียวกันเป๊ะ แต่พิมพ์แค่สองตัวอักษร และไม่กลืน
// ตัวอักษรฐานสิบหกที่ตามมาข้างหลังแบบที่ \x1b เคยทำ
public const string Reset = " \e [0m" ;
public static string Bold (string text ) => $" \e [1m{ text}{ Reset}" ; ใช้ตอนไหน ใช้กับ code ที่พ่นรหัสสี ANSI ลงเทอร์มินัล ซึ่งแทบทุกลำดับขึ้นต้นด้วย ESC · ไม่ควร ไล่แก้ \u001b ของเดิมทั้ง project ให้เป็น \e มันไม่เปลี่ยนอะไรเลยนอกจากขนาดของ diff · ระวัง ว่านี่คือ feature ของ ภาษา ไม่ใช่ของ library file ที่ใช้ \e จะ compile ไม่ผ่านทันทีใน project ที่ตั้ง LangVersion ต่ำกว่านี้ — ถ้า share file ข้าม project อยู่ให้เช็คก่อน
Small optimizations to overload resolution involving method groups เปลี่ยนการพิมพ์ กลุ่ม method หาชนิดธรรมชาติเจอในกรณีที่เดิมหาไม่เจอ # link ถาวรของ Small optimizations to overload resolution involving method groups
ตอน compiler หา “ชนิดธรรมชาติ” ของกลุ่ม method (เช่นตอนเขียน var f = SomeMethod;) มันจะตัดตัวเลือกที่เรียกด้วยรูปแบบนั้นไม่ได้ทิ้งไปก่อนทีละขอบเขต กลุ่ม method ที่เดิมกำกวมจนไม่มีชนิดธรรมชาติเลยจึงมีขึ้นมาได้
code เดิม
private static void Audit (Order order ) { }
private static void Audit <T >(T item ) { }
public static void Demo ()
{
// มีตัวเลือกสองตัว กลุ่ม method จึงไม่มีชนิดธรรมชาติ ต้องสะกดชนิด delegate เองทุกครั้ง
Action <Order > log = Audit;
log (new Order ());
}
code ใหม่
private static void Audit (Order order ) { }
private static void Audit <T >(T item ) { }
public static void Demo ()
{
// ตัวเลือกที่เรียกด้วยรูปแบบนี้ไม่ได้ถูกตัดทิ้งก่อน เหลือตัวเดียว var จึงทำงาน
var log = Audit;
log (new Order ());
} ใช้ตอนไหน ได้มาฟรีตอนอัป version ไม่มีอะไรต้องเขียนเพิ่ม — จุดที่เคยต้องสะกดชนิด delegate ออกมาเองใช้ var ได้ · ระวัง ว่ามันเปลี่ยน ผล ของการเลือกโอเวอร์โหลดใน code เดิมได้ด้วย ไม่ใช่แค่ปลดล็อกของที่เคยพัง จุดที่เคยเลือกตัวหนึ่งอาจเลือกอีกตัวหลังอัป version ถ้าเจอพฤติกรรมเพี้ยนหลังอัป ให้สงสัยจุดที่แปลงกลุ่ม method เป็น delegate ก่อน · อย่า พึ่ง var กับกลุ่ม method ใน code ที่คนอื่นต้องอ่านต่อ การสะกดชนิด delegate ไว้ตรง ๆ ยังบอกเจตนาได้ดีกว่า
Implicit indexer access in object initializers เปลี่ยนการพิมพ์ ใช้ตัวดำเนินการ ^ ใน object initializer ได้ # link ถาวรของ Implicit indexer access in object initializers
ตัวดำเนินการ ^ (“นับจากท้าย”) ใช้ในนิพจน์ object initializer ได้แล้ว จากเดิมที่เขียนได้เฉพาะนอก initializer เท่านั้น ผลที่ได้คือการเรียก indexer ตัวเดียวกันเป๊ะ
code เดิม
private sealed class Slots
{
public int [] Recent { get ; } = new int [3 ];
}
public static int Newest ()
{
// ^ ใน object initializer ยังไม่ได้ ต้องสร้างให้เสร็จก่อนแล้วค่อยเขียนทีหลัง
var slots = new Slots ();
slots.Recent[^ 1 ] = 7 ;
return slots.Recent[2 ];
}
code ใหม่
private sealed class Slots
{
public int [] Recent { get ; } = new int [3 ];
}
// ^1 ("จากท้าย") ใช้ใน object initializer ได้แล้ว ไม่ต้องนับความยาวตอนเขียน
public static int Newest () => new Slots { Recent = { [^ 1 ] = 7 } }.Recent[2 ]; ใช้ตอนไหน ใช้ตอนตั้งค่าเริ่มต้นให้ช่องท้าย ๆ ของ collection ที่ความยาวคงที่ ในนิพจน์เดียวจบ เช่น buffer หรือ slot ที่จองไว้ล่วงหน้า · ไม่ควร เอาไปใช้กับ collection ที่ความยาวมาจากข้อมูลจริง เพราะ ^1 บนของว่างคือ IndexOutOfRangeException ตอนรัน ไม่ใช่ error ตอน compile · นี่คือ feature ที่เปลี่ยนแค่ ตำแหน่ง ที่ไวยากรณ์เดิมวางได้ ไม่ได้เพิ่มความสามารถอะไรให้ตัวภาษา
Partial properties and indexers เปลี่ยนการพิมพ์ property และ indexer ที่แยกเป็นสองครึ่งได้ # link ถาวรของ Partial properties and indexers
ในชนิดที่ประกาศเป็น partial ประกาศ property หรือ indexer เป็น partial ได้แล้ว ครึ่งหนึ่งเป็นแค่การประกาศที่ไม่มี body อีกครึ่งเป็นตัว implement — กลไกเดียวกับที่ partial method ใช้มานาน
code เดิม
public partial class OrderCounter
{
// property เป็น partial ไม่ได้ ต้องแปลงเป็น partial method แล้วห่อทับเอง
public int Placed => GetPlaced ();
private partial int GetPlaced ();
}
public partial class OrderCounter
{
private partial int GetPlaced () => 0 ;
}
code ใหม่
public partial class OrderCounter
{
// ครึ่งประกาศ — ไม่มี body เลย ตัวสร้าง code จะเติมให้ใน file อีกฝั่ง
public partial int Placed { get ; }
public partial string this [int index ] { get ; }
}
public partial class OrderCounter
{
public partial int Placed => 0 ;
public partial string this [int index ] => index.ToString ();
} ใช้ตอนไหน feature นี้เขียนไว้ให้ source generator ใช้ — คนเขียนประกาศหน้าตาของ property ไว้ใน file ของตัวเอง ตัวสร้าง code เติม body ให้ใน file ที่มันสร้าง · ไม่ควร แยก property เป็นสองครึ่งด้วยมือใน code ที่คนเขียนล้วน ๆ ไม่ได้อะไรกลับมานอกจากต้องเปิด2 file เพื่ออ่านของชิ้นเดียว · ระวัง อย่าอ่านมันว่า compiler ตรวจอะไรเพิ่ม กติกาที่ว่าสองครึ่งต้องมีลายเซ็นตรงกันคือกติกาเดิมของ partial method ที่ยืดมาคลุม property ด้วยเท่านั้น
params collections ประสิทธิภาพ/interop params รับ collection ชนิดอื่นนอกจากอาร์เรย์ได้ # link ถาวรของ params collections
params ไม่ผูกกับอาร์เรย์อีกต่อไป ใช้กับ Span<T>, ReadOnlySpan<T>, IEnumerable<T> และชนิด collection อื่นที่ compiler รู้จักได้ ส่วนจุดเรียกเขียนเหมือนเดิมทุกตัวอักษร
code เดิม
// params เป็นอาร์เรย์ได้อย่างเดียว ทุกครั้งที่เรียกจึงมีอาร์เรย์เกิดขึ้นบน heap หนึ่งตัว
public static Money Total (params OrderLine [] lines )
{
decimal sum = 0m ;
foreach (OrderLine line in lines)
sum += line.UnitPrice.Amount * line.Quantity;
return new Money (sum, "THB" );
}
public static Money Two (OrderLine a , OrderLine b ) => Total (a, b);
code ใหม่
// params รับ ReadOnlySpan ได้แล้ว buffer ของ argument จึงไปอยู่บนสแต็ก
// จุดเรียกข้างล่างหน้าตาเหมือนเดิมทุกตัวอักษร ต่างกันแค่ของที่ไม่ได้ถูกจองบน heap
public static Money Total (params ReadOnlySpan <OrderLine > lines )
{
decimal sum = 0m ;
foreach (OrderLine line in lines)
sum += line.UnitPrice.Amount * line.Quantity;
return new Money (sum, "THB" );
}
public static Money Two (OrderLine a , OrderLine b ) => Total (a, b); ใช้ตอนไหน จุดที่คุ้มคือ API ที่ถูกเรียกถี่มากในเส้นทางร้อน เปลี่ยน parameter เป็น params ReadOnlySpan<T> แล้วอาร์เรย์ที่เคยเกิดทุกครั้งที่เรียกก็หายไป โดยผู้เรียกไม่ต้องแก้อะไรเลย · อย่า ไล่เปลี่ยน params T[] ทุกตัวใน project — สำหรับ library ที่ปล่อยไปแล้วมันคือ binary breaking change และ method ที่ถูกเรียกวันละไม่กี่ครั้งไม่ได้อะไรกลับมา · ระวัง ว่าค่าที่รับมาเป็น span เก็บไว้ใช้ต่อหลัง method จบไม่ได้ ถ้าต้องเก็บ ต้องคัดลอกออกมาเอง
New lock type and semantics ประสิทธิภาพ/interop ชนิด Lock ที่ compiler รู้จักเป็นพิเศษ # link ถาวรของ New lock type and semantics
ถ้าเป้าหมายของ statement lock เป็น System.Threading.Lock compiler จะสร้าง code ที่เรียก Lock.EnterScope() แทน Monitor.Enter/Monitor.Exit — ตัว statement ที่คนเขียนพิมพ์ไม่เปลี่ยนสักตัวอักษร เปลี่ยนแค่ IL ที่ออกมา
code เดิม
// เป้าหมายของ lock เป็น object ธรรมดา ซึ่งลงเอยที่ Monitor.Enter/Exit เสมอ
private readonly object _gate = new object ();
private int _placed ;
public int Record ()
{
lock (_gate)
{
return ++ _placed;
}
}
code ใหม่
// เป้าหมายเป็น System.Threading.Lock compiler จึงสร้าง code ที่เรียก
// Lock.EnterScope() แทน — ตัว statement lock เขียนเหมือนเดิมทุกตัวอักษร
private readonly System .Threading .Lock _gate = new ();
private int _placed ;
public int Record ()
{
lock (_gate)
{
return ++ _placed;
}
} ใช้ตอนไหน ใช้กับ critical section ที่ถูกชนถี่ ๆ ซึ่ง Lock ทำงานได้ดีกว่าและเข้ากับ model thread ของ .NET รุ่นใหม่มากกว่า · ระวัง กับดักที่ compiler ตั้งคำเตือนไว้ให้ (CS9216): ถ้าเผลอเก็บ Lock ไว้ในตัวแปรชนิด object มันจะเงียบ ๆ ตกกลับไปใช้ Monitor แบบเดิม ได้พฤติกรรมที่ถูกต้องแต่ไม่ได้สิ่งที่ตั้งใจมาเอา · ไม่ควร ไล่เปลี่ยนตัวล็อกทั้ง project โดยไม่วัด และต้องอยู่บน .NET 9 ขึ้นไป เพราะชนิดนี้เป็นของ library ไม่ใช่ของภาษา — บน target framework ที่เก่ากว่านั้นจะได้ error ว่าหาชนิดไม่เจอ ไม่ใช่ error เรื่อง version ภาษา
ref locals and unsafe contexts in iterators and async methods ประสิทธิภาพ/interop ref local และ block unsafe อยู่ใน method async กับ iterator ได้ # link ถาวรของ ref locals and unsafe contexts in iterators and async methods
ข้อห้ามเหมารวมที่ว่า “method async และ iterator ห้ามมี ref local หรือ block unsafe เลย” ถูกแทนด้วยกฎที่แคบลง: มีได้ ตราบใดที่มันไม่คร่อม await หรือ yield return
code เดิม
public static async Task <int > BumpAsync (int [] quantities )
{
// ref local ใน method async ถูกห้ามทั้งหมด ไม่ว่าจะคร่อม await หรือไม่
// ต้องวิ่งผ่านดัชนีใหม่ทุกครั้งที่แตะค่า
quantities[0 ]++ ;
await Task.Yield ();
return quantities[0 ];
}
code ใหม่
public static async Task <int > BumpAsync (int [] quantities )
{
// ref local อยู่ใน method async ได้แล้ว ตราบใดที่มันไม่คร่อม await
ref int first = ref quantities[0 ];
first++ ;
await Task.Yield ();
return quantities[0 ];
} ใช้ตอนไหน ส่วนใหญ่ได้ประโยชน์ทางอ้อม — Span<T> และเพื่อน ๆ ทำงานได้ในที่ที่เดิมทำไม่ได้ code ที่เราเขียนเองแทบไม่ต้องแก้อะไรเลย · อย่า อ่าน feature นี้ว่า “เอา Span ข้าม await ได้แล้ว” นั่นยังห้ามอยู่และเป็นข้อห้ามที่มีเหตุผล เพราะ state machine ของ async ต้องขึ้น heap · ระวัง ตอนเขียน unsafe ใน iterator: อายุของ pointer ผูกกับรอบที่ MoveNext ทำงาน ไม่ใช่กับตัว method ที่ตาเห็นในซอร์ส
ref struct types implement interfaces ประสิทธิภาพ/interop ref struct ประกาศ implement interface ได้ # link ถาวรของ ref struct types implement interfaces
ref struct ใส่ชื่อ interface ในรายการฐานได้แล้ว และ compiler บังคับให้มีสมาชิกครบตามสัญญา แต่ แปลง ตัวมันไปเป็นชนิด interface ยังไม่ได้ เพราะการแปลงนั้นคือการ box
code เดิม
public interface ILineView { int Quantity { get ; } }
// ref struct เขียน ": ILineView" ไม่ได้เลย ทำได้แค่ให้สมาชิกหน้าตาตรงกัน
// แล้วเขียนกำกับไว้ในเอกสารว่ามันตั้งใจทำสัญญาข้อไหน
public ref struct LineCursor
{
private readonly ReadOnlySpan <OrderLine > _lines ;
public LineCursor (ReadOnlySpan <OrderLine > lines ) => _lines = lines;
public int Quantity => _lines.Length;
}
code ใหม่
public interface ILineView { int Quantity { get ; } }
// ใส่ชื่อ interface ในรายการฐานได้แล้ว — แต่แปลงเป็น ILineView ยังไม่ได้
// เพราะการแปลงนั้นคือการ box สมาชิกจึงเรียกได้ผ่าน type parameter เท่านั้น
public ref struct LineCursor : ILineView
{
private readonly ReadOnlySpan <OrderLine > _lines ;
public LineCursor (ReadOnlySpan <OrderLine > lines ) => _lines = lines;
public int Quantity => _lines.Length;
} ใช้ตอนไหน เป็นชิ้นส่วนที่ต้องมีก่อน ไม่ใช่ของที่หยิบไปใช้ตรง ๆ — มันคู่กับ allows ref struct ซึ่งเป็นทางเดียวที่เรียกสมาชิกของสัญญานั้นได้จริง · อย่า คาดหวังว่าติด interface แล้วจะส่ง ref struct เข้า parameter ชนิด interface ได้ compiler ปฏิเสธที่จุดแปลงชนิด · ระวัง เรื่อง default interface member: ref struct เรียกของที่ interface เตรียม body ไว้ให้ไม่ได้ ต้อง implement เองครบทุกตัว
Overload resolution priority ประสิทธิภาพ/interop แอตทริบิวต์ที่บอกว่าโอเวอร์โหลดตัวไหนควรถูกเลือกก่อน # link ถาวรของ Overload resolution priority
[OverloadResolutionPriority(n)] ให้ผู้เขียน library บอก compiler ว่าโอเวอร์โหลดตัวไหน “ดีกว่า” เมื่อมีหลายตัวที่เรียกได้พอกัน ตัวที่เลขสูงกว่าชนะก่อนที่กฎ “ตัวไหนเจาะจงกว่า” ตามปกติจะได้ทำงาน
code เดิม
// เกณฑ์เดียวคือ "ตัวไหนเจาะจงกว่า" ผู้เรียกที่ส่ง string จึงได้ตัวที่รับ string เสมอ
// อยากให้ย้ายไปตัวที่เร็วกว่า ต้องให้ผู้เรียกทุกคนไล่แก้จุดเรียกเอง
public static bool IsPlaced (ReadOnlySpan <char > status ) => status.SequenceEqual ("placed" );
public static bool IsPlaced (string status ) => status == "placed" ;
public static bool Demo () => IsPlaced ("placed" );
code ใหม่
// ติดลำดับความสำคัญไว้ที่ตัวใหม่ ผู้เรียกเดิมที่ส่ง string จะย้ายมาตัวนี้ตอน compile ใหม่
// โดยไม่ต้องแก้ code สักบรรทัด — ทั้งสองตัวยังเรียกได้เหมือนเดิมทุกประการ
[System .Runtime .CompilerServices .OverloadResolutionPriority (1 )]
public static bool IsPlaced (ReadOnlySpan <char > status ) => status.SequenceEqual ("placed" );
public static bool IsPlaced (string status ) => status == "placed" ;
public static bool Demo () => IsPlaced ("placed" ); ใช้ตอนไหน เขียนไว้ให้คนทำ library เพิ่มโอเวอร์โหลดที่เร็วกว่า (เช่นตัวที่รับ ReadOnlySpan<char>) แล้วผู้เรียกเดิมย้ายมาให้เองตอน compile รอบถัดไป · อย่า ใช้มันใน code ของ application ตัวเอง ถ้าโอเวอร์โหลดถูกเลือกผิดใน code ที่เราคุมทั้งหมด ให้แก้ลายเซ็นหรือแก้จุดเรียกตรง ๆ อย่าซ่อนคำตอบไว้ในแอตทริบิวต์ที่คนอ่านจุดเรียกมองไม่เห็น · ระวัง ว่ามันมีผลตอน compile เท่านั้น code ที่ compile ไปแล้วไม่ขยับตาม และตัวแอตทริบิวต์เป็นของ .NET 9 ขึ้นไป
C# 14 — 8 feature
field backed properties เปลี่ยนการออกแบบ property ที่เรียกที่เก็บค่าของตัวเองว่า field # link ถาวรของ field backed properties
compiler รับประกันว่า: ที่เก็บค่าของ property ที่มี setter ตรวจค่า ไม่เหลือชื่อให้สมาชิกตัวอื่นใน class เดียวกันอ้างถึงอีกต่อไป — field มีความหมายเฉพาะข้างใน accessor เท่านั้น (เขียนนอก accessor ได้ error CS0103) และ field ที่ compiler สร้างชื่อว่า <Currency>k__BackingField ซึ่งพิมพ์เป็น C# ไม่ได้ · ทุกการเขียนลงที่เก็บค่าจากซอร์สจึงเดินผ่าน setter เสมอ เพราะไม่มีทางอื่นให้เดิน ไม่ใช่เพราะทีมมีวินัย
ใน accessor ของ property เขียนคำว่า field เพื่ออ้างถึงที่เก็บค่าที่ compiler สังเคราะห์ให้ได้ตรง ๆ จึงใส่เงื่อนไขตรวจค่าไว้ใน set ได้โดยไม่ต้องประกาศ field รองรับเอง
code เดิม
public sealed class Price
{
private string _currency = "THB" ;
public string Currency
{
get => _currency;
set => _currency = value ?? throw new ArgumentNullException (nameof (value));
}
// field ยังอยู่ในสายตาของทั้ง class ใครในนี้เขียนทับตรง ๆ ก็ข้ามด่านตรวจไปได้
public void Reset () => _currency = null ! ;
}
code ใหม่
public sealed class Price
{
// ไม่มีชื่อให้สมาชิกตัวไหนใน class อ้างถึงที่เก็บค่าได้ ทางเดียวที่เขียนได้คือผ่าน setter
public string Currency
{
get => field;
set => field = value ?? throw new ArgumentNullException (nameof (value));
} = "THB" ;
} ใช้ตอนไหน คุ้มที่สุดกับ property ที่มีเงื่อนไขตรวจค่า แปลงค่า หรือ normalize ตอนเซ็ต ซึ่งเดิมต้องแลกด้วย field ที่ทั้ง class เขียนได้ · ไม่ควร ไล่เปลี่ยน property ที่ get; set; เปล่า ๆ มาเขียนแบบนี้ ไม่ได้อะไรเพิ่มนอกจากตัวอักษร · ระวัง สองเรื่อง: ถ้าใน class มีสมาชิกชื่อ field อยู่ก่อน คำใน accessor จะไปผูกกับที่เก็บค่าตัวใหม่แทน และได้ warning CS9258 ที่บอกให้เขียน this.field หรือ @field ถ้าหมายถึงตัวเดิม · และ initializer = "THB" ในตัวอย่างนี้ไม่ใช่ของประดับ ตัดออกเมื่อไรจะได้ warning CS9264 ว่า property ที่ห้ามเป็น null ยังไม่มีค่าตอนออกจาก constructor
Extension members เปลี่ยนการพิมพ์ block extension ที่ประกาศ property และสมาชิก static ได้ # link ถาวรของ Extension members
block extension(T x) { ... } ใน class static ประกาศตัวรับไว้ครั้งเดียวแล้วแขวนสมาชิกได้ทั้งกลุ่ม — ไม่จำกัดแค่ method อีกต่อไป property อินเด็กเซอร์ และสมาชิก static ก็เขียนแบบ extension ได้แล้ว
code เดิม
public static class OrderExtensions
{
// ได้แค่ method — property เขียนแบบ extension ไม่ได้ และตัวรับต้องพิมพ์ซ้ำทุกตัว
public static bool IsFinished (this Order order ) =>
order.Status == OrderStatus.Delivered;
public static bool IsOpen (this Order order ) =>
order.Status == OrderStatus.Placed;
}
code ใหม่
public static class OrderExtensions
{
extension (Order order )
{
// property เป็น extension ได้แล้ว และไม่ต้องพิมพ์ this Order ซ้ำทุกตัว
public bool IsFinished => order.Status == OrderStatus.Delivered;
public bool IsOpen => order.Status == OrderStatus.Placed;
}
} ใช้ตอนไหน ใช้ตอนมี extension หลายตัวที่แขวนกับชนิดเดียวกันจนการพิมพ์ตัวรับซ้ำเริ่มบังเนื้อหา หรือตอนอยากให้ของที่อ่านอย่างเดียวหน้าตาเป็น property แทน method · อย่า เข้าใจว่ามันทำให้ชนิดนั้น กลายเป็น อะไรใหม่: extension member ไม่นับว่า implement interface และไม่ผ่าน generic constraint — ประกาศ IsFinished เป็น extension แล้วส่ง Order เข้า method ที่ where T : IFinishable ยังได้ error CS0311 เหมือนเดิม มันคือการเรียก static ที่เขียนให้อ่านเหมือนสมาชิก ไม่ใช่การเพิ่มสมาชิกจริง · ระวัง ว่า block นี้ต้องอยู่ใน class static ระดับบนสุดเท่านั้น เอาไปซ้อนใน class อื่นได้ error CS9283
Null-conditional assignment เปลี่ยนการพิมพ์ เขียนค่าใส่ผ่าน ?. ได้ ไม่ใช่แค่อ่าน # link ถาวรของ Null-conditional assignment
ตัวดำเนินการ ?. และ ?[] ยืนอยู่ฝั่งซ้ายของเครื่องหมายเท่ากับได้แล้ว ถ้าตัวรับเป็น null ทั้งการเขียนถูกข้าม และฝั่งขวาก็ไม่ถูกประเมินเลย
code เดิม
public static void Confirm (Order ? order )
{
if (order is not null )
{
order.Status = OrderStatus.Confirmed;
}
}
code ใหม่
public static void Confirm (Order ? order )
{
// ถ้า order เป็น null ทั้งบรรทัดถูกข้าม ฝั่งขวาก็ไม่ถูกประเมิน
order? .Status = OrderStatus.Confirmed;
} ใช้ตอนไหน ใช้กับการเซ็ตค่าที่ตัวรับเป็น null ได้จริงและ 'ไม่มีตัวรับ' แปลว่า 'ไม่ต้องทำอะไร' เช่นการอัปเดต object ที่เป็นทางเลือก · ระวัง ว่ามันกลืนกรณี null ไปเงียบ ๆ ถ้าตัวรับที่เป็น null คือความผิดพลาดที่ควรดัง อย่าใช้ตัวนี้ ให้เช็คแล้วโยน exception ตามเดิม · ไม่ควร ต่อ ?. กันยาว ๆ ใน statement เดียว เพราะจุดที่ทำให้ทั้งบรรทัดกลายเป็น no-op มีได้หลายจุดและอ่านไม่ออกจากบรรทัดนั้น · และ compiler ไม่ได้เริ่มเตือน code ที่เขียนแบบเดิม มันแค่เพิ่มรูปประโยคให้ ไม่ได้เพิ่มการตรวจอะไรใหม่
nameof supports unbound generic types เปลี่ยนการพิมพ์ nameof รับชนิด generic ที่ยังไม่ระบุ argument ได้ # link ถาวรของ nameof supports unbound generic types
เขียน nameof(List<>) ได้แล้ว โดยไม่ต้องหาชนิดสมมติมาใส่ในวงเล็บมุมก่อน ผลลัพธ์ยังเป็นชื่อเปล่า ๆ ของชนิดเหมือนเดิม
code เดิม
// ต้องใส่ชนิดสมมติเข้าไปหนึ่งตัว ทั้งที่ผลลัพธ์ไม่ได้ขึ้นกับมันเลย
public static string CacheKey () => nameof (List<int> );
code ใหม่
// เว้นวงเล็บมุมว่างไว้ได้ ผลลัพธ์ยังเป็น "List" เท่าเดิม
public static string CacheKey () => nameof (List<> ); ใช้ตอนไหน ใช้ตอนต้องการชื่อของชนิด generic ไปทำ key cache ชื่อ log หรือข้อความ diagnostic ที่ไม่เกี่ยวกับ argument ชนิดเลย · ระวัง ว่าผลลัพธ์ตัดวงเล็บมุมและ argument ทิ้งทั้งหมด nameof(Dictionary<,>) ได้ "Dictionary" เฉย ๆ ซึ่งชนกับชนิดชื่อเดียวกันที่มีจำนวน parameter ต่างกันได้ ถ้า key ต้องแยกจากกันจริง ๆ อย่าพึ่งค่านี้ตัวเดียว ให้ใช้ typeof(...).FullName แทน · การรับประกันว่า 'ชื่อนี้ถูกตรวจตอน compile' เป็นของ nameof มาแต่เดิม ตัวนี้ไม่ได้เพิ่มอะไรให้
Modifiers on simple lambda parameters เปลี่ยนการพิมพ์ parameter ของแลมบ์ดาติด modifier ได้โดยไม่ต้องเขียนชนิด # link ถาวรของ Modifiers on simple lambda parameters
parameter ของแลมบ์ดาที่มี ref, out, in, scoped หรือ ref readonly เขียนแค่ชื่อได้แล้ว ชนิดยังถูกอนุมานจาก delegate เหมือน parameter ที่ไม่มี modifier
code เดิม
public delegate bool TryParse (string text , out int result );
// พอมี modifier ตัวเดียว ต้องกลับไปเขียนชนิดของทุก parameter ให้ครบ
public static readonly TryParse ParseQuantity =
(string text , out int result ) => int .TryParse (text, out result);
code ใหม่
public delegate bool TryParse (string text , out int result );
// ตัวรับ parameter เหลือแค่ชื่อกับ modifier ชนิดมาจาก delegate เหมือนเดิม
public static readonly TryParse ParseQuantity =
(text , out result ) => int .TryParse (text, out result); ใช้ตอนไหน ใช้กับแลมบ์ดาที่ผูกกับ delegate ซึ่งมี out/ref อยู่แล้ว เช่นตระกูล TryParse หรือ callback ที่คืนค่าทาง parameter · ระวัง ว่ามันใช้ได้เฉพาะตอนที่ชนิดของ delegate รู้แน่อยู่แล้ว — แลมบ์ดาที่ปล่อยให้ compiler หาชนิดเองยังต้องเขียนชนิดครบ · ไม่ควร ตีความว่า code ปลอดภัยขึ้น กฎที่บังคับให้เขียนค่าลง out ให้ครบทุกเส้นทางก่อนออกจากตัวแลมบ์ดายังเป็นกฎเดิมตัวเดิม ที่หายไปคือหน้าที่พิมพ์ชนิดซ้ำเท่านั้น · ref กับ out ในแลมบ์ดายังห้ามถูกจับไปเก็บใน closure เหมือนเดิม
partial events and constructors เปลี่ยนการพิมพ์ event และตัวสร้างแยกเป็นฝั่งประกาศกับฝั่งเติมได้ # link ถาวรของ partial events and constructors
partial ใช้กับ event และ constructor ได้แล้ว ฝั่งหนึ่งประกาศว่ามีสมาชิกตัวนี้ อีกฝั่งเขียน body ให้ — ชุดเดียวกับที่ method และ property ทำได้อยู่ก่อน
code เดิม
public partial class Notifier // ตัวสร้างกับ event ต้องเขียนมือทั้งคู่
{
public Notifier (string channel ) => Init (channel);
public event EventHandler Placed { add { } remove { } }
partial void Init (string channel ); // generator แบ่งงานได้แค่ทาง method partial
}
public partial class Notifier
{
partial void Init (string channel ) => Console.WriteLine (channel);
}
code ใหม่
public partial class Notifier // ฝั่งประกาศ: บอกว่ามีอะไรบ้าง ไม่มี body สักบรรทัด
{
public partial event EventHandler Placed ;
public partial Notifier (string channel );
}
public partial class Notifier // ฝั่งเติม: generator เขียน file นี้ให้ได้ทั้งสองอย่าง
{
public partial event EventHandler Placed { add { } remove { } }
public partial Notifier (string channel ) => Console.WriteLine (channel);
} ใช้ตอนไหน มีไว้ให้ source generator เป็นหลัก — ฝั่งที่คนเขียนประกาศสัญญาไว้ ฝั่งที่เครื่องสร้างเติม body ให้ใน file แยก · ไม่ควร เอามาแบ่ง class ที่คนเขียนเองทั้งสองฝั่ง เพราะตัวสร้างที่ประกาศอยู่ file หนึ่งแล้ว body อยู่อีก file คือของที่ตามอ่านยากขึ้นโดยไม่ได้อะไรกลับมา · ระวัง ตอนอ่าน error: ถ้า compile ด้วย version ภาษาที่ยังไม่มี feature นี้ คำว่า partial จะถูกอ่านเป็นชื่อชนิดที่คืนค่า แล้วได้ error ตามมาเป็นพรวดตั้งแต่ CS0246 ยัน CS0111 — บรรทัดที่บอกความจริงคือ CS9260 ซึ่งอยู่ท้ายสุด ไม่ใช่บรรทัดแรก
More implicit conversions for Span<T> and ReadOnlySpan<T> ประสิทธิภาพ/interop Span<T> และ ReadOnlySpan<T> แปลงข้ามกันได้โดยปริยายมากขึ้น # link ถาวรของ More implicit conversions for Span<T> and ReadOnlySpan<T>
compiler รู้จักการแปลงโดยปริยายระหว่าง Span<T>, ReadOnlySpan<T> และอาร์เรย์เพิ่มอีกชุด รวมถึงแบบ covariant ของ ReadOnlySpan<T> และนับการแปลงชุดนี้ตอนเลือก overload ด้วย
code เดิม
public static ReadOnlySpan <object > Widen (Span <string > names )
{
// ต้องคัดลอกทั้งชุดขึ้น heap ก่อน ถึงจะได้ชนิดที่ผู้เรียกต้องการ
object [] buffer = new object [names.Length];
for (int i = 0 ; i < names.Length; i++ ) buffer[i] = names[i];
return buffer;
}
code ใหม่
// ชนิดต่างกันแค่ความ "กว้าง" ของสมาชิก แปลงตรง ๆ ได้เลย ไม่มีการคัดลอก
public static ReadOnlySpan <object > Widen (Span <string > names ) => names; ใช้ตอนไหน คุ้มในเส้นทางร้อนที่เดิมต้องแวะ .AsSpan() หรือคัดลอกลง buffer ใหม่เพียงเพื่อให้ชนิดตรงกับลายเซ็นที่มีอยู่ · ระวัง ว่าการเลือก overload เปลี่ยนไปด้วย: method ที่เคยกำกวมหรือเคยไปลงตัวที่รับ object อาจย้ายไปลงตัวที่รับ span แทน แปลว่า code เดิมที่ไม่ได้แก้สักบรรทัดอาจเรียก method คนละตัวหลังยก version ภาษา ให้ไล่ดูจุดที่มี overload ทั้งสองแบบก่อน · อย่า เข้าใจว่า compiler เริ่มค้ำอายุของหน่วยความจำให้: span ที่ชี้ไปยังของที่หมดอายุแล้วยังเป็นความรับผิดชอบของคนเขียนเหมือนเดิม
user-defined compound assignment operators ประสิทธิภาพ/interop เขียนตัวดำเนินการ += ของชนิดตัวเองได้ตรง ๆ # link ถาวรของ user-defined compound assignment operators
ประกาศ public void operator +=(...) เป็นสมาชิก instance ได้ compiler จะเรียกตัวนี้แทนการสังเคราะห์ x = x + y ให้ ตัวดำเนินการจึงแก้ค่าในที่เดิมได้โดยไม่ต้องสร้าง instance ใหม่
code เดิม
public struct Counter
{
public int Value ;
public Counter (int value ) => Value = value;
// += มีทางเดียว: สร้าง instance ใหม่ทั้งก้อนแล้วเขียนทับตัวเดิม
public static Counter operator + (Counter left , int amount ) =>
new Counter (left.Value + amount);
}
code ใหม่
public struct Counter
{
public int Value ;
// เขียนลงตัวรับตรง ๆ ไม่ต้องสร้าง instance ใหม่ทั้งก้อนแล้วคัดลอกกลับ
public void operator += (int amount ) => Value += amount;
} ใช้ตอนไหน คุ้มกับชนิดที่ก้อนใหญ่หรือถือ buffer ไว้ข้างใน แล้วถูกสะสมค่าใน loop จนต้นทุนการสร้างและคัดลอกวัดได้จริง เช่นตัวสะสมผลรวมหรือ builder ที่เป็น value type · อย่า ใส่ให้ชนิดที่คุณโฆษณาว่าไม่เปลี่ยนค่า (immutable) — ทั้งประโยชน์และความหมายของมันคือการแก้ค่าในที่เดิม ใส่แล้วคำโฆษณานั้นเป็นเท็จทันที · ระวัง ว่าถ้าประกาศทั้ง operator + และ operator += ไว้คู่กัน สองเส้นทางนี้เพี้ยนจากกันได้เงียบ ๆ ตอนมีคนแก้ตัวใดตัวหนึ่ง — compiler ไม่ได้ตรวจว่ามันให้ผลตรงกัน มันบังคับแค่รูปร่างของตัวดำเนินการเอง (ต้องคืน void และต้องเป็นสมาชิก instance)