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

C# 8 ถึง 14: feature ไหน​เปลี่ยน​วิธี​ออกแบบ feature ไหน​แค่​เปลี่ยน​วิธี​พิมพ์

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 เดียว
  • ชั้น​ประสิทธิภาพ/interop18 ตัว (19.1%) — คุณ มี​สิทธิ์​เพิกเฉย จนกว่า​จะ​มี​ผล​วัด​มา​ขอ

ถ้า​แยก​สาม​กอง​นี้​ออก​จาก​กัน​ได้ commit “modernize” ข้าง​บน​จะ​กลาย​เป็น​ของ​ที่​รีวิว​ได้

กับ feature ส่วน​ใหญ่ คำถาม​นี้​ตอบ​ง่าย ที่​เหลือ​คือ​สาม​รูปแบบ​ที่​หลอก​คน​ซ้ำ ๆ และ​สอง​อัน​ใน​นั้น​เคย​หลอก​หน้า​นี้​มา​แล้ว​จริง ๆ

เริ่ม​จาก​สอง​ตัว​ที่​ตอบ​ง่าย​ก่อน:

ที่​เหลือ​คือ​สาม​รูปแบบ​ที่​หลอก​คน​ได้​ซ้ำ ๆ ทั้ง​สาม​อัน​ถูก​เถียง​กัน​จริง​ตอน​จัด​ชั้น file ข้อมูล​ของ​หน้า​นี้ และ​สอง​อัน​แรก​ถึง​ขั้น​เคย​จัด​ผิด​มา​แล้ว

กับดัก​ที่ 1 — “ไม่มี​ไวยากรณ์​เดิม​ให้​เทียบ” ไม่​ได้​แปล​ว่า​ชั้น​ออกแบบ

หัวข้อ​ที่​มีชื่อ​ว่า “กับดัก​ที่ 1 — “ไม่มี​ไวยากรณ์​เดิม​ให้​เทียบ” ไม่​ได้​แปล​ว่า​ชั้น​ออกแบบ”

ตัวอย่าง​ที่​ชัด​ที่สุด​คือ module initializers ของ C# 9 ช่อง “code เดิม” ของ​มัน​ใน​คลัง​ท้าย​หน้า​ไม่มี​อะไร​ให้​เทียบ​เลย เพราะ​ก่อนหน้า​นั้น​ไม่มี​วิธี​เขียน​สิ่ง​นี้​แม้แต่​วิธี​เดียว

ฟัง​ดูเหมือน​ของ​ใหญ่​ทันที แต่​พอ​เอา​คำถาม​ไป​ถาม​จริง ๆ:

  • มัน​พิสูจน์​อะไร​ให้​เรา​เพิ่ม​ไหม — ไม่
  • มัน​บังคับ​อะไร​ที่​เมื่อ​วาน​ไม่​บังคับ​ไหม — ไม่

สิ่ง​ที่​มัน​เพิ่ม​คือ ความ​สามารถ​ตอน​รัน คือ​มี​ที่​ให้​แขวน code ที่​ต้อง​ทำงาน​ก่อน​ใคร ความ​แปลก​ใหม่​ของ​ไวยากรณ์ กับ​น้ำหนัก​ของ​สิ่ง​ที่ compiler รับประกัน เป็น​คนละ​เรื่อง​กัน

ใน​หน้า​นี้​มี7 feature ที่​ช่อง “code เดิม” ว่างเปล่า และ​มัน​กระจาย​อยู่​ทั้ง​สาม​ชั้น:

  • ref fieldsชั้น​ออกแบบ แต่​เป็น​ด้วย​เหตุผล​คนละ​เรื่อง​กับ​ความ​ว่าง​ของ​ช่อง​นั้น​โดย​สิ้นเชิง คือ​มัน​ปฏิเสธ​โปรแกรม​ของ​คุณ​ด้วย CS8374 เมื่อ​อ้างอิง​ที่​เก็บ​ไว้​จะ​อยู่​นาน​เกิน​ที่​เก็บ​ที่​มัน​ชี้​ไป
  • nint/nuintชั้น​การ​พิมพ์
  • [SkipLocalsInit]ชั้น​ประสิทธิภาพ

ช่องว่าง​นั้น​จึง​ไม่​ได้​ตัดสิน​อะไร​ให้​สัก​ตัว​เดียว

กับดัก​ที่ 2 — “compiler ฟ้อง​เพิ่ม​ได้​อีก​หนึ่ง​รหัส” ก็​ไม่​ได้​แปล​ว่า​ชั้น​ออกแบบ

หัวข้อ​ที่​มีชื่อ​ว่า “กับดัก​ที่ 2 — “compiler ฟ้อง​เพิ่ม​ได้​อีก​หนึ่ง​รหัส” ก็​ไม่​ได้​แปล​ว่า​ชั้น​ออกแบบ”

pattern and ของ C# 9 ทำให้​ช่วง​ที่​เป็น​ไป​ไม่​ได้​อย่าง > 20 and < 5 โดน compiler ฟ้อง ส่วน && ที่​เขียน​เรื่อง​เดียวกัน​ไม่มี​ใคร​ฟ้อง

ฟัง​แล้ว​เข้า​เกณฑ์​ชั้น​ออกแบบ​เป๊ะ ๆ จน​กระทั่ง​ไป​เจอ​สอง​ข้อเท็จจริง​นี้:

  1. ตัว​วิเคราะห์ pattern ที่​เป็น​ไป​ไม่​ได้​ตัว​นั้น​มี​มา​ก่อน C# 9switch ที่​ไม่มี and, or หรือ not อยู่​เลย​สัก​ตัว ก็​ยัง​ได้ CS8510 ที่ LangVersion 8 อยู่ดี and ไม่​ได้​พา​ตัว​ตรวจ​ตัว​ใหม่​มา มัน​แค่​ยื่น​รูป​ประโยค​ใหม่​ให้​ตัว​ตรวจ​ตัว​เดิม
  2. pattern เชิง​เปรียบเทียบ​ใช้ได้​กับ​ค่า​คงที่​ที่​คน​เขียน​พิมพ์​เอง​ตอน compile เท่านั้น — ค่าที่​อ่าน​มา​ตอน​รัน​ใช้​ไม่​ได้​เลย (ได้ CS9135)

สิ่ง​ที่​รหัส​นั้น​จับ​ได้​จึง​เป็น การ​พิมพ์​ผิด ใน​บรรทัด​ที่​คน​เขียน​เพิ่ง​พิมพ์​ไป​เมื่อ​ครู่ ไม่ใช่​ข้อ​ผิดพลาด​ของ​การ​ออกแบบ​ที่​จะ​รอด​ไป​ถึง​โปร​ดัก​ชัน

กับดัก​ที่ 3 — “ถอน​ข้อ​ห้าม​ออก” ไม่ใช่​การ​เริ่ม​พิสูจน์

หัวข้อ​ที่​มีชื่อ​ว่า “กับดัก​ที่ 3 — “ถอน​ข้อ​ห้าม​ออก” ไม่ใช่​การ​เริ่ม​พิสูจน์”

ข้อ​นี้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 ใบ แต่​เป็น​เส้น​แนวโน้ม​ที่​อ่าน​ได้​ด้วย​ตา

versionชั้น​ออกแบบชั้น​การ​พิมพ์ประสิทธิภาพ/interopรวม
C# 859317
C# 9315321
C# 10311216
C# 1149215
C# 120617
C# 1314510
C# 141528
8–1011 (20.4%)35 (64.8%)8 (14.8%)54
11–146 (15.0%)24 (60.0%)10 (25.0%)40
รวม17 (18.1%)59 (62.8%)18 (19.1%)94

แถว​สรุป​บอก​อย่าง​หนึ่ง แถว​ราย version บอก​อีก​อย่าง

หัวข้อ​ที่​มีชื่อ​ว่า “แถว​สรุป​บอก​อย่าง​หนึ่ง แถว​ราย version บอก​อีก​อย่าง”

สอง​แถว​สรุป​ข้าง​ล่าง​บอกว่า​สัดส่วน​ของ​ชั้น​ออกแบบ ลดลง จาก​หนึ่ง​ใน​ห้า​เหลือ​หนึ่ง​ใน​เจ็ด ส่วนสัดส่วน​ของ​ชั้น​ประสิทธิภาพ เกือบ​สอง​เท่า (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 หลัง ๆ ปลอดภัย​กว่า มัน​แปล​ว่า​อันตราย​ย้าย​ที่

ของ​ที่​มี​กับดัก​คม​ที่สุด​ใน​หน้า​นี้​แทบ​ทั้งหมด​อยู่​ใน​ชั้น​ประสิทธิภาพ ไม่ใช่​ชั้น​ออกแบบ:

  • params ที่​รับ collection ได้ — เปลี่ยน​แล้ว​เป็น binary breaking change โดย​จุด​เรียก​ไม่​ขยับ​สัก​ตัว​อักษร
  • operator += ที่​เขียน​เอง​ได้ — ทำให้ += ทุก​จุด​ที่​มี​อยู่​เดิม​เปลี่ยน​จาก “สร้าง​ใหม่​แล้ว​ผูก​ใหม่” เป็น “แก้​ค่า​ใน​ที่​เดิม”
  • การ​แปลง Span โดย​ปริยาย​ชุด​ใหม่ — จุด​เรียก​ที่​เคย​กำกวม​หรือ​เคย​ไป​ลง​โอเวอร์​โหลด​ที่​รับ object อาจ ย้าย​ไป​ลงตัว​ที่​รับ span แทน​หลัง​ยก 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 ใน​วินาที​เดียว

และ​คำ​เตือน​หลัก​พัน​มี​ค่า​เท่ากับ​ศูนย์​เสมอ เพราะ​ไม่มี​ใคร​อ่าน แผนที่​ใช้ได้​จริง​จึง​เป็น​แผนที่​ยอม​ให้​มัน​ช้า

  1. เปิด​ที​ละ project ไม่ใช่​ทั้ง solution — สวิตช์​นี้​อยู่​ใน file .csproj ไม่ใช่​ที่ solution เริ่ม​ที่ project ที่​พึ่งพา project อื่น​น้อย​ที่สุด ซึ่ง​มัก​เป็น project ที่​มี​แต่ type ของ domain พอ​มัน​ถูก annotate แล้ว project ที่​เหลือ​จะ​ได้​ข้อมูล​ชุด​นั้น​ไป​ใช้​ต่อ​ทันที​ตอน​ถึง​คิว​ของ​มัน ถ้า project เดียว​ก็​ยัง​ใหญ่​เกิน​ไป ยัง​มี #nullable enable ที่​เปิด​ที​ละ file ได้​อีก​ชั้น​หนึ่ง
  2. อยู่​ที่​ระดับ​คำ​เตือน​ให้​นาน​พอ แล้ว​ค่อย​เลื่อน​เป็น error — การ​ตั้ง <WarningsAsErrors>Nullable</WarningsAsErrors> ตั้งแต่​วัน​แรก​ฟัง​ดู​เด็ดขาด​ดี แต่​ผล​จริง​คือ​ทีม​จะ​ไล่​โปรย ! เพื่อ​ให้ build ผ่าน​ให้​ทัน​ของ​ที่​ต้อง​ส่ง ซึ่ง​แย่​กว่า​ไม่​เปิด​เลย เพราะ code จะ​ดูเหมือน​ผ่าน​การ​ตรวจ​แล้ว​ทั้ง​ที่​ไม่ เลื่อน​เป็น error เมื่อ project นั้น​สะอาด​จริง​แล้ว​เท่านั้น และ​เลื่อน​ที​ละ project เหมือน​กัน
  3. 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# 11pattern ของ 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 จะ​เห็น​เส้น​นี้:

  1. ref fields ทำให้ ref struct เก็บ​อ้างอิง​ไป​ยัง​ที่​เก็บ​ของ​คน​อื่น​ได้ โดย​มี compiler ตรวจ​อายุ​ให้ ซึ่ง​เป็น​สิทธิ์​ที่​เดิม Span<T> ได้​รับ​เป็น​กรณี​พิเศษ​จาก runtime
  2. ref struct ประกาศ implement interface ได้ แต่ แปลง ตัวเอง​ไป​เป็น​ชนิด interface ยัง​ไม่​ได้ เพราะ​การ​แปลง​นั้น​คือ​การ box — ตัว​มัน​เอง​จึง​แทบ​ไม่มี​ประโยชน์​ถ้า​อยู่​ลำพัง
  3. allows ref struct คือ​ทาง​เดียว​ที่​จะ​เรียก​สมาชิก​ของ​สัญญา​นั้น​ได้​จริง​ผ่าน generic
  4. การ​แปลง 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 เดียวกัน เพราะ​คน​รีวิว​ใช้​เกณฑ์​คนละ​อัน​กับ​มัน ส่วน​กอง​ที่​สาม​ไม่​ได้​อยู่​บน​เส้นทาง​อัปเกรด​เลย มัน​รอ​ผล​วัด​อยู่​คนละ​ที่

  • file-scoped namespacenamespace Kaen.Ordering; แทน​การ​ครอบ​ทั้ง file ด้วย​ปีกกา
  • global using — ประกาศ using ครั้ง​เดียว​ให้​มี​ผล​ทั้ง project
  • target-typed newnew() โดย​ไม่​ต้อง​พิมพ์​ชื่อ​ชนิด​ซ้ำ​เมื่อ​ฝั่ง​ซ้าย​บอก​ไว้​แล้ว

เหตุผล​ที่​มัน​ปลอดภัย​ไม่ใช่​เพราะ​มัน​เล็ก แต่​เพราะ มัน​ไม่​เปลี่ยน​อะไร​ที่ 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 ก่อนหน้า​ได้​จริง (อีก​เจ็ด​ช่องว่าง​เพราะ​เขียน​แบบ​เดิม​ไม่​ได้​เลย)

นอกจาก​เก้า​ตัว​นั้น​แล้ว ยัง​มี​อีก​สี่​จุด​ที่​คำ​โฆษณา​ของ​คลัง​ครอบ​ไป​ไม่​ถึง และ​ทั้ง​สี่​จุด​ควร​ได้​รับ​การ​อ่าน​ด้วย​ตา​คน

หนึ่ง — Interceptors ไม่​อยู่​ใน​คลัง และ​ไม่ใช่​เพราะ​ลืม

หัวข้อ​ที่​มีชื่อ​ว่า “หนึ่ง — Interceptors ไม่​อยู่​ใน​คลัง และ​ไม่ใช่​เพราะ​ลืม”

หน้า 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 ไม่ใช่​ไวยากรณ์​ที่​จะ​หยิบ​มา​พิมพ์​เอง

สอง — nint/nuint แสดง​ตัวเอง​ไม่​ได้​เลย​บน target framework สมัย​ใหม่

หัวข้อ​ที่​มีชื่อ​ว่า “สอง — nint/nuint แสดง​ตัวเอง​ไม่​ได้​เลย​บน target framework สมัย​ใหม่”

การ​ที่​สอง​ชื่อ​นี้​กลาย​เป็น​ชื่อ​เรียก​ของ 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 ที่​สอง​มี​แล้ว​ไม่​ได้​ใช้

อ่าน​ต่อ

  • C# สมัย​ใหม่​สำหรับ​งาน DDD — ถ้า​อยาก​ลงมือ​เขียน record, init, required, field และ value object จริง​บน domain Order ตัว​เดียว​กับ​ใน​คลัง​ข้าง​ล่าง
  • MVC + Service กับ Clean + DDD — ถ้า​คำถาม​ถัด​ไป​ของ​คุณ​คือ​เรื่อง​รูปร่าง​ของ project ไม่ใช่​เรื่อง​ไวยากรณ์

ข้าง​ล่าง​นี้​คือ 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

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 ของ​เรา​ที่​หลุด​ออก​ไป​ตาม​ตัว​อักษร

New format for the #line pragma

เปลี่ยน​การ​พิมพ์ #line ระบุ​ช่วง​เริ่ม-จบ​และ​ตำแหน่ง​ตัว​อักษร​ได้ link ถาวร​ของ New format for the #line pragma

รูปแบบ​ใหม่ #line (บรรทัดเริ่ม, ตัวอักษรเริ่ม) - (บรรทัดจบ, ตัวอักษรจบ) ระยะเยื้อง "ไฟล์" แม็ป code กลับ​ไป​ยัง ช่วง ใน file ต้นทาง​ได้ ต่าง​จาก​รูปแบบ​เดิม​ที่​ชี้​ได้​แค่​ทั้ง​บรรทัด

code เดิม

ไม่มี code เดิม​ให้​เทียบ — ผลอยู่​ที่ compiler ไม่ใช่​ที่​ไวยากรณ์

code ใหม่

// บรรทัดถัดจากนี้ถูกรายงานว่าอยู่ที่ Menu.razor บรรทัด 7 ตัวอักษรที่ 5–32
// ส่วนบรรทัดที่อยู่ถัดลงไปอีกนับต่อเป็น 8, 9, ... จนกว่าจะเจอ #line default
#line (7, 5) - (7, 32) 6 "Menu.razor"
public static string Rendered(string name) => $"เมนู {name}";
#line default

ใช้​ตอน​ไหน feature นี้​เขียน​ให้ เครื่อง ใช้ — ตัว​แปล​ภาษา​อย่าง Razor ที่​สร้าง file C# ขึ้น​มา​แล้ว​ต้อง​พา​คำ​เตือน​กับ​เบรก​พอ​ยต์กลับ​ไป​ให้​ตรง​จุด​ใน file ที่​คน​เขียน​จริง · ไม่​ควร มี​รูปแบบ​นี้​อยู่​ใน code ที่​คน​เขียน​เอง​เลย เห็น​เมื่อไร​แปล​ว่า​กำลัง​อ่าน​ผลลัพธ์​ของ​เครื่องมือ​อยู่ · ระวัง ตอน​อ่าน​ข้อความ error: บรรทัด​ที่ compiler รายงาน​คือ​บรรทัด​หลัง​แม็ป​แล้ว ไม่ใช่​บรรทัด​ใน file ที่​เปิด​อยู่

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)