โหมดมืด
บทที่ 10 — Modern C#
C# ออก version ใหม่ทุกปี — บทนี้รวม feature สำคัญตั้งแต่ C# 9 ถึง C# 14 (.NET 10) ที่ทำให้ code modern อ่านง่ายขึ้นมาก
📌 C# version กับ .NET version ออกพร้อมกัน — C# 9 มากับ .NET 5, C# 12 มากับ .NET 8, C# 13 มากับ .NET 9, C# 14 มากับ .NET 10
สถานะ ณ เวลาเขียน: C# 13 (.NET 9) = GA (General Availability — ออกใช้งานจริงแล้ว); C# 14 (.NET 10) = RC (Release Candidate — รุ่นทดสอบก่อนออกจริง) — feature C# 14 เช่น
fieldkeyword และ partial property อาจเปลี่ยนก่อน release จริง ควรตรวจสอบเวอร์ชันล่าสุดก่อนใช้งานจริง
หัวข้อหลัก: record, pattern matching, nullable reference, primary constructor, collection expression, raw string, Span<T>, source generator และ feature C# 14 ใหม่ ๆ เช่น field keyword และ partial property
1. Record (C# 9+) — value-equality + immutable by default
record (C# 9+) คือ type สำหรับเก็บข้อมูลที่เขียนสั้น — ประกาศบรรทัดเดียวได้ property, constructor และ ToString ครบ
มาพร้อม value-equality (เทียบด้วยค่า ไม่ใช่ที่อยู่หน่วยความจำ) และเป็น immutable (แก้ไม่ได้) โดย default
เหมาะกับงานพวก DTO (Data Transfer Object = object ที่ใช้แค่ขนข้อมูลส่งไปมา ไม่มี logic) และ model (แบบจำลองข้อมูลในระบบ เช่น User, Product, Order):
csharp
public record Person(string Name, int Age);
var a = new Person("Alice", 30);
var b = new Person("Alice", 30);
Console.WriteLine(a == b); // True — value equality
Console.WriteLine(a);
// > Person { Name = Alice, Age = 30 } — auto ToString
// with expression — non-destructive mutation
var older = a with { Age = 31 };
Console.WriteLine(a.Age); // 30 — a ไม่เปลี่ยน
Console.WriteLine(older.Age); // 31
// deconstruction
var (name, age) = a;Record vs class:
| class | record (class) | record struct | |
|---|---|---|---|
| Equality | reference | value | value |
| ToString | type name | auto | auto |
| with-expression | ❌ | ✅ | ✅ |
| Immutable by default | ❌ | ✅ | ❌ |
| Mutable allowed | ✅ | ✅ (declare set) | ✅ |
| Storage | heap (ref) | heap (ref) | stack (value) |
หมายเหตุตาราง: record เป็น immutable by default เฉพาะแบบ positional record (ที่ประกาศบรรทัดเดียวแบบ
Person(string Name, int Age)) — properties ที่ได้เป็น init-only แก้ไม่ได้หลังสร้าง แต่ถ้า record ที่เขียน body เอง (ดูหัวข้อถัดไป) แล้วเปลี่ยน property ให้มีsetแทนinitก็จะกลายเป็น mutable ได้เหมือนกัน
Record with body
csharp
public record Person(string Name, int Age)
{
public bool IsAdult => Age >= 18;
public string Greet() => $"Hi {Name}";
// override init-only by adding set:
// public int Age { get; set; } = Age;
}Record struct (value type record)
csharp
public record struct Point(double X, double Y);
public readonly record struct Vec3(double X, double Y, double Z);ใช้สำหรับ data ขนาดเล็ก ไม่ allocate heap
2. Pattern Matching (advanced — review บทที่ 3)
C# 9-11 ขยาย pattern อย่างมาก — ดูตัวอย่างใช้ใน real code:
csharp
// type ที่ใช้ในตัวอย่างด้านล่าง
record OrderItem(string Sku);
record Order(string Status, decimal Total, List<OrderItem> Items);csharp
public string Categorize(Order o) => o switch
{
// ⚠️ ลำดับสำคัญ! switch จะลองทีละ arm จากบนลงล่าง — เจอ match แรกก็หยุด
// ถ้าวาง { Status: "pending" } ก่อน { Items: [] }
// → pending order ที่ items ว่างจะถูกจัดเป็น "pending" ไม่ใช่ "empty"
// จัด arm เฉพาะเจาะจง (specific) ไว้บน, arm กว้าง ๆ ไว้ล่าง
{ Items: [] } => "empty",
{ Status: "pending", Total: > 1000 } => "high-value pending",
{ Status: "pending" } => "pending",
{ Items: [{ Sku: "GOLD" }, ..] } => "starts with gold",
{ Items.Count: > 10 } => "bulk order",
_ => "regular"
};{ Items.Count: > 10 } = property pattern เข้าถึง nested property ตรง ๆ
⚠️ ถ้า
Itemsเป็น array ใช้.Lengthแทน.Count(List ใช้.Count, array ใช้.Length)
3. Nullable Reference Types (NRT) — recap + advanced
Nullable Reference Types (NRT = ชนิดอ้างอิงที่บอกได้ว่าเป็น null ได้หรือไม่) ใช้กับ reference type เช่น string, class (ไม่ใช่ value type เช่น int/bool) — ให้ compiler ช่วยจับ null ก่อน runtime ลดบั๊ก NullReferenceException
หลักการเบื้องต้น:
string= ห้ามเป็น nullstring?= อนุญาตให้เป็น null- มี attribute (ป้ายกำกับโค้ดที่เขียนใน
[วงเล็บเหลี่ยม]เพื่อบอกข้อมูลพิเศษให้ compiler/runtime) เช่น[NotNullWhen],[MaybeNull]ไว้บอก compiler ถึงเงื่อนไข null ที่ซับซ้อน (เช่น "ถ้า method นี้ return true แปลว่า out param ไม่ null") - runtime (ตอนรันโปรแกรม) — NRT ตรวจช่วงคอมไพล์เท่านั้น ตอนรันไม่ตรวจ:
Attribute สำหรับ nullable analysis
csharp
using System.Diagnostics.CodeAnalysis;
private static readonly Dictionary<string, string> _cache = new();
// NotNullWhen — บอก compiler ว่าถ้า return true → out param ไม่ null
public bool TryGet(string key, [NotNullWhen(true)] out string? value)
{
return _cache.TryGetValue(key, out value);
}
if (TryGet("greeting", out var v))
Console.WriteLine(v.Length); // ไม่มี warning เพราะ NotNullWhenอื่น ๆ (เป็น reference list — ไม่ต้องจำทั้งหมด แค่รู้ว่ามีไว้ใช้ตอนเจอ):
[NotNull]— บอก compiler ว่า out/return ค่านี้ไม่มีทาง null แม้ type จะเป็น?[MaybeNull]— บอกว่าค่านี้ "อาจจะ" null ได้ แม้ type จะไม่มี?[NotNullIfNotNull(nameof(x))]— บอกว่าถ้า parameterxไม่ null ผลลัพธ์ก็จะไม่ null ตามไปด้วย[MemberNotNull(nameof(_field))]— บอกว่าหลังเรียก method นี้แล้ว field_fieldจะไม่ null แน่นอน[DisallowNull],[AllowNull]— บังคับ/อนุญาตให้ parameter รับ null ได้ ต่างจาก type ที่ประกาศไว้
NRT migration tip
csharp
// pragma เพื่อ silence warning ในส่วนที่ migrate ยังไม่เสร็จ
#nullable disable
// old code
#nullable enable4. Primary Constructor (C# 12+)
💡 ตัวอย่างถัดไปใช้
IRepository<User>(จากบท 7) และILogger<UserService>(Microsoft.Extensions.Logging) ซึ่งได้รับผ่าน DI (Dependency Injection — สอนในส่วน dotnet/) — ตอนนี้แค่ดูโครงสร้าง primary constructor ได้เลย ไม่ต้องรันก็ได้
csharp
// class
public class UserService(IRepository<User> repo, ILogger<UserService> logger)
{
public async Task<User?> Get(string id)
{
logger.LogInformation("get {Id}", id);
return await repo.FindById(id);
}
}
// struct — ตั้งชื่อหลีกเลี่ยง System.Range
public readonly struct IntRange(int Start, int End)
{
public int Length => End - Start;
}repo, logger, Start, End = ตัวแปร "primary ctor" — ใช้ได้ทุก method ใน class
⚠️ Pitfall: primary ctor parameter ไม่ใช่ field ปกติ — ทุก method ที่ใช้คือกำลัง capture (ยืมใช้) → ระวัง mutability (ความสามารถในการแก้ค่า)
ความจริงที่หลายคนไม่รู้: compiler สร้าง hidden field (ตัวแปร instance ที่ซ่อนอยู่เบื้องหลัง) ไว้เก็บค่าพวกนี้จริง ๆ ดังนั้นถ้า assign ทับใน method ค่าจะคงอยู่ (ไม่ใช่ "แค่ parameter" แบบ method ปกติ):
csharppublic class Counter(int start) { public int Current => start; public void Inc() => start++; // ✅ ทำได้! และค่าจะคงอยู่ } var c = new Counter(0); c.Inc(); c.Inc(); Console.WriteLine(c.Current); // 2ดังนั้นถ้าอยากให้ "อ่านอย่างเดียว" ให้ assign เข้า
readonlyfield เอง:csharppublic class Service(IRepository<User> repo) { private readonly IRepository<User> _repo = repo; // ตอนนี้ห้ามแก้ }
5. Collection Expression (C# 12+)
C# 12 เพิ่มไวยากรณ์สร้าง collection แบบสั้นด้วย [...] — ใช้ได้กับ array, List, Span ฯลฯ โดย compiler เลือกวิธีสร้างที่เหมาะที่สุดให้ และรองรับ spread (..) รวม collection:
csharp
// แทน
int[] a = new int[] { 1, 2, 3 };
List<int> b = new List<int> { 1, 2, 3 };
// ใช้
int[] a2 = [1, 2, 3];
List<int> b2 = [1, 2, 3];
ReadOnlySpan<int> c = [1, 2, 3];
// spread
int[] combined = [..a2, 4, 5, ..b2];Compiler เลือกวิธีสร้างที่ดีที่สุดตามชนิดเป้าหมาย — เร็วกว่าเขียนเอง (เป็นมิตรกับ AOT = Ahead-Of-Time compile คือการแปลงโปรแกรมเป็น native code ที่รันได้ทันทีตั้งแต่ตอน compile โดยไม่ต้องรอ JIT แปลตอนเปิดโปรแกรม — รายละเอียดเพิ่มเป็นเรื่องขั้นสูงอยู่ในหัวข้อ 17 ข้ามได้ถ้ายังไม่สนใจ)
ที่น่าสนใจ: backing storage (พื้นที่จริงที่เก็บข้อมูล) ขึ้นอยู่กับ target type ที่เรากำหนดไว้ ไม่ใช่ syntax [...] — เช่น List<int> จะไปอยู่บน heap เหมือนเดิม
ส่วน ReadOnlySpan<int> compiler อาจเลือกใช้ stackalloc (จองหน่วยความจำบน stack โดยตรง ไม่ใช่ heap) หรือสร้าง inline array (array ขนาดคงที่ที่ฝังอยู่ใน stack เลย ไม่ต้อง allocate แยก) ให้อยู่บน stack แทน — เพื่อหลบการ allocation (จองหน่วยความจำ) บน heap ที่ช้ากว่า
6. Required Members (C# 11+) — recap
ทบทวน required (C# 11+) — บังคับให้ผู้สร้าง object กำหนดค่า property นั้นตอน initialize ไม่งั้น compile ไม่ผ่าน ทำให้มั่นใจว่าข้อมูลสำคัญถูกตั้งครบโดยไม่ต้องเขียน constructor:
csharp
public class User
{
public required string Email { get; init; }
public required string Name { get; init; }
}
var u = new User { Email = "a@b", Name = "A" };
// var u2 = new User(); // ❌ compile error7. File-scoped namespace (C# 10+)
C# 10 ให้เขียน namespace แบบ "ทั้งไฟล์" (namespace X; จบด้วย ;) แทนการครอบทั้งไฟล์ด้วยปีกกา — ลด indent หนึ่งระดับ โค้ดสะอาดขึ้น (1 ไฟล์มักมี namespace เดียวอยู่แล้ว):
csharp
// แทน
namespace MyApp.Models
{
public class User { }
}
// ใช้ (ลด indent 1 ระดับ)
namespace MyApp.Models;
public class User { }ใช้กับทุก file ใหม่ตั้งแต่ C# 10+
8. Global Using (C# 10+)
csharp
// ไฟล์ GlobalUsings.cs
global using System;
global using System.Collections.Generic;
global using System.Linq;
global using System.Threading.Tasks;
global using MyApp.Common;ทุกไฟล์ใน project เห็น using พวกนี้โดยไม่ต้องเขียน
💡 ตั้งแต่ .NET 6,
ImplicitUsings=enableให้ default พอแล้ว — ใช้ global using สำหรับ custom
9. Init-only setter + Required (recap)
init (แทน set) ทำให้ property กำหนดค่าได้แค่ตอนสร้าง object แล้วล็อกไม่ให้แก้อีก — ได้ immutability แต่ยังใช้ object initializer ได้ คู่กับ required ก็บังคับให้ตั้งครบ:
csharp
public record Config
{
public required string ConnectionString { get; init; }
public int Timeout { get; init; } = 30;
}immutable หลัง construct + บังคับว่าต้อง set
10. Raw String Literal (C# 11+)
"Raw string literal" (C# 11+) ใช้ triple quote """...""" เขียนข้อความหลายบรรทัดโดยไม่ต้อง escape เครื่องหมายคำพูดหรือ backslash — ดีมากสำหรับ SQL, JSON, regex ที่มีอักขระพิเศษเยอะ:
csharp
// triple quote — ไม่ต้อง escape
string sql = """
SELECT * FROM Users
WHERE Status = 'active'
""";
// interpolation ที่ raw (ใช้ $$ และ {{ }})
string name = "Alice";
string json = $$"""
{
"name": "{{name}}",
"tags": ["a", "b"]
}
""";ดีมากสำหรับ SQL, JSON, regex
🚨 อย่าใช้ string interpolation ต่อ SQL กับค่าจากผู้ใช้ — เปิดช่อง SQL injection ทันที
csharp// ❌ อันตราย — input ของผู้ใช้ถูกฝังเป็นข้อความใน SQL ตรง ๆ string sql = $""" SELECT * FROM Users WHERE Name = '{userInput}' """; // ✅ ใช้ parameterized query — ค่าถูกส่งแยกจาก SQL string sql = """ SELECT * FROM Users WHERE Name = @name """; using var cmd = new SqlCommand(sql, conn); cmd.Parameters.AddWithValue("@name", userInput);ตัวอย่างถัดไปใช้ EF Core (Entity Framework Core — library จัดการฐานข้อมูลที่จะสอนใน dotnet/) ยังไม่ต้องเข้าใจ syntax ทั้งหมดตอนนี้ — อ่านเพื่อจำหลักการ "ห้ามต่อ string ใส่ SQL" ไว้ก่อนพอ:
EF Core ก็เช่นเดียวกัน — ใช้
FromSql(EF Core 7+, จัดการ parameterize ให้อัตโนมัติ) ไม่ใช่FromSqlRawกับ string interpolation:csharp// ✅ ปลอดภัย — FromSql (EF Core 7+) รับ interpolated string literal ตรง ๆ แล้วแยกค่าออกมา parameterize ให้อัตโนมัติ db.Users.FromSql($"SELECT * FROM Users WHERE Name = {userInput}")(
FromSqlInterpolatedยังใช้ได้แต่เป็น API เก่า —FromSqlคือ modern API)⚠️ ข้อควรระวัง: ที่ปลอดภัยเพราะเราส่ง interpolated string literal (ตัว
$"..."ตรง ๆ) เข้าFromSqlโดยตรง — compiler จะแปลงมันเป็น type พิเศษที่แยกค่าออกมา parameterize ให้ ถ้าเผลอไปประกาศเก็บในstringvariable ก่อนแล้วค่อยส่งเข้าFromSql(เช่นstring sql = $"...{userInput}..."; db.Users.FromSql(sql)) จะกลายเป็นstringธรรมดา สูญเสียการ parameterize และกลับมาเสี่ยง SQL injection เหมือนเดิม
11. UTF-8 String Literal (C# 11+)
ต่อท้าย string ด้วย u8 (C# 11+) จะได้ byte ของ UTF-8 ตรง ๆ
ปกติเขียน "Hello" จะได้ string ก่อน แล้วต้องแปลงเป็น byte ตอนรัน (runtime) ซึ่งช้า
ใช้ u8 compiler ฝัง byte ลงไปตั้งแต่ตอน compile — เหมาะกับงาน network/protocol ที่ทำงานกับ byte ล้วน และต้องการ performance:
csharp
ReadOnlySpan<byte> bytes = "Hello"u8;
// → byte[] ของ UTF-8 — ไม่ allocate string ตอน runtimeใช้ใน network protocol / file format
12. List Pattern (C# 11+) — recap
"List pattern" (C# 11+) ให้ match รูปร่างของ array/list ใน switch ได้ — เช่นเช็คว่าว่าง, มีกี่ตัว, หรือขึ้นต้น/ลงท้ายด้วยค่าไหน (.. แทนส่วนที่เหลือ) เขียน logic ตามโครงสร้างข้อมูลได้กระชับ:
csharp
int[] arr = [1, 2, 3];
string desc = arr switch
{
[] => "empty",
[var x] => $"one: {x}",
[_, _] => "two",
[1, .., 3] => "starts 1 ends 3",
[var first, .. var rest] => $"first={first}, rest len={rest.Length}"
};13. Generic Math (C# 11+)
C# 11 เพิ่ม "static abstract member" ใน interface ทำให้เขียน generic ที่ใช้ตัวดำเนินการคณิตศาสตร์ได้ — เช่น Sum<T> ที่ทำงานกับทุกชนิดตัวเลข (int, double, decimal) ผ่าน constraint INumber<T> ไม่ต้องเขียนซ้ำ:
csharp
public static T Sum<T>(IEnumerable<T> source) where T : INumber<T>
{
T total = T.Zero;
foreach (var x in source) total += x;
return total;
}
Sum(new[] { 1, 2, 3 }); // ok
Sum(new[] { 1.0, 2.0, 3.0 }); // ok
Sum(new[] { 1m, 2m, 3m }); // ok decimalINumber<T> มี static abstract Zero, operator +, -, etc. — ทำให้เขียน generic math ได้
14. Default Lambda Parameter (C# 12+)
C# 12 ให้ lambda มีค่า default ของพารามิเตอร์ได้ (เหมือน method) — เรียกโดยไม่ส่งค่าก็ใช้ default:
csharp
var greet = (string name = "World") => $"Hello, {name}!";
greet(); // Hello, World!
greet("Alice"); // Hello, Alice!15. Params Collections (C# 13+)
เดิม params ต้องเป็น array (ซึ่ง allocate ทุกครั้งที่เรียก) — C# 13 ให้ params เป็น Span/collection อื่นได้ ทำให้รับจำนวน argument ไม่จำกัดโดยไม่ต้องจองหน่วยความจำ heap:
csharp
void Print(params ReadOnlySpan<int> nums)
{
foreach (var n in nums) Console.WriteLine(n);
}
Print(1, 2, 3); // no allocation (Span บน stack)ก่อนหน้านี้ params ต้องเป็น array → allocate
💡 C# 13 ยังเพิ่ม
params IEnumerable<T>,params IReadOnlyList<T>,params List<T>ด้วย — ทำให้รับ collection ที่มีอยู่แล้วได้โดยไม่ต้องสร้าง array ใหม่
16. ref struct + Span<T>
⏭️ โซนขั้นสูง — มือใหม่ข้ามได้
Span<T>และref structเป็นเครื่องมือเพิ่ม performance ที่มีข้อจำกัดเยอะ (ห้าม async, ห้ามเก็บใน field) ใช้เฉพาะโค้ดที่ต้องเร็วเป็นพิเศษ มือใหม่อ่านผ่าน ๆ พอ ยังไม่ต้องใช้
Span<T> = "มุมมอง" (view) ของหน่วยความจำที่เรียงต่อกัน (array, stack, native) โดยไม่ก๊อปข้อมูล
ref struct = struct ที่บังคับให้อยู่บน stack เท่านั้น (อยู่บน heap ไม่ได้)
csharp
Span<int> stackSpan = stackalloc int[100];
ReadOnlySpan<char> sub = "Hello, World".AsSpan(7, 5); // "World"
// ใช้สำหรับ parse efficiently
int Parse(ReadOnlySpan<char> input)
{
int result = 0;
foreach (char c in input)
{
if (!char.IsDigit(c)) throw new FormatException();
result = result * 10 + (c - '0');
}
return result;
}
Parse("123".AsSpan());Span<T> เป็น ref struct → ห้ามอยู่บน heap → ห้ามเก็บใน field, ห้ามใช้ใน async — แต่เร็วมากเพราะไม่ต้อง allocate (จองหน่วยความจำ)
17. Source Generators (preview overview)
⏭️ โซนขั้นสูง — มือใหม่ข้ามได้
Source generator เป็นเรื่องของการสร้างโค้ดอัตโนมัติตอน compile ซึ่งมือใหม่แทบไม่ต้องเขียนเอง (แค่ใช้ของที่มากับ library) อ่านผ่าน ๆ พอ
C# 9+ มี source generator = ตัวสร้างโค้ดให้อัตโนมัติตอน compile (ไม่ต้องเขียนเองตอนรัน)
ตัวอย่างที่เจอบ่อย:
[JsonSerializable]ของSystem.Text.Json→ สร้าง serializer (ตัวแปลง object ↔ JSON) ให้- ปกติ JSON serializer ใช้ reflection (การส่องดูโครงสร้าง type ตอนรัน) ซึ่งช้าและ AOT ไม่รองรับ
- source generator แทนด้วยโค้ดที่สร้างไว้แล้ว → เร็วขึ้น + เข้ากับ AOT (Ahead-Of-Time compile = compile เป็น native ก่อนรัน)
[GeneratedRegex]→ compile regex (รูปแบบค้นหาข้อความ) ตั้งแต่ compile[LoggerMessage]→ log แบบไม่ต้อง allocate string[LibraryImport](C# 12+) → แทน[DllImport]เดิม สำหรับเรียก native lib (library ที่เขียนด้วย C/C++) แบบ AOT-friendly — งานทั่วไปไม่ต้องใช้
csharp
// ต้องอยู่ใน partial class — source generator จะ generate body ของ DateRegex() ให้
public partial class Validators
{
[GeneratedRegex(@"^\d{4}-\d{2}-\d{2}$")]
private static partial Regex DateRegex();
public static bool IsIsoDate(string s) => DateRegex().IsMatch(s);
}
Validators.IsIsoDate("2024-05-23"); // trueattribute พวกนี้คือ pattern ที่ทำให้โค้ดใช้กับ AOT compile ได้ — ถ้าเล็งทำ Native AOT (เช่น minimal API ที่บูตเร็ว) ใช้ทั้งหมดที่กล่าวมา
18. Modern C# Style Checklist
โค้ดที่ "ดู modern" จะมี:
- file-scoped namespace
recordสำหรับ DTO/data- top-level statement สำหรับ entry point
- primary constructor สำหรับ service
requiredแทน big constructorinitsetter- collection expression
[1, 2, 3] - pattern matching แทน if/else chain
- raw string literal สำหรับ SQL/JSON
- LINQ chain แทน manual loop
staticlambda เพื่อ no closureSpan<T>ใน hot path
🛠️ Checkpoint 10 — ลงมือทำ
- แปลง class
User(มี constructor + override Equals/GetHashCode) ให้เป็นrecord - ใช้
withexpression สร้าง user ใหม่ที่อายุ +1 - ใช้ primary ctor + collection expression สร้าง
OrderService(IRepository<Order> repo)กับ methodRecent() => [..repo.FindAll().Take(10)] - เขียน method
bool TryParseEmail(string input, [NotNullWhen(true)] out string? email)ใช้ NRT attribute - ใช้ raw string + interpolation สร้าง SQL ที่ inject parameter (ผ่าน
@param) - เปลี่ยน method ที่รับ
stringแล้ว split → รับReadOnlySpan<char>แทน
สรุปบทที่ 10
- record = value equality + immutable +
withexpression - pattern matching ขยายไปถึง list, property nested, relational
- NRT + attribute = หลีกเลี่ยง NRE ได้เกือบสมบูรณ์
- primary ctor ลดโค้ดของ class ที่ DI dependency
- collection expression
[1, 2, 3]+ spread[..a, ..b] - raw string ทำให้ SQL/JSON literal สวย
Span<T>เร็วใน hot path (parse, slice) — ห้าม async- Source generator = code-gen ที่ compile-time (AOT-friendly)