โหมดมืด
บทที่ 9 — Lambda + Delegate + LINQ
LINQ (Language Integrated Query = การ query/ค้นหาที่ฝังอยู่ในตัวภาษา) เป็นหนึ่งใน feature ที่ทำให้ C# โดดเด่น (query = ค้นหา/กรอง/แปลงข้อมูล)
LINQ ใช้ syntax เดียวกัน query ได้ทั้ง collection ใน RAM, database, XML, JSON
เบื้องหลังของ LINQ คือ 3 อย่างทำงานร่วมกัน:
- delegate — ตัวแปรที่ชี้ไปยัง method (คล้าย "ตัวชี้" ไปยังฟังก์ชัน)
- extension method — method ที่เพิ่มเข้าไปยัง type ที่มีอยู่แล้วโดยไม่แก้ source เดิม เช่น
.Where(),.Select()ที่เพิ่มให้ทุกIEnumerable<T> - lambda — ฟังก์ชันนิรนาม (= ฟังก์ชันที่ไม่ต้องตั้งชื่อ) เขียนสั้น ๆ ส่งต่อเป็นค่าได้ทันที เช่น
x => x * 2
สามอย่างนี้จะอธิบายทีละตัวด้านล่าง
1. Delegate — ตัวแปรที่ชี้ไปยัง method (pointer ไปยัง method)
csharp
// declare delegate type
public delegate int Operation(int a, int b);
int Add(int a, int b) => a + b;
int Sub(int a, int b) => a - b;
Operation op = Add;
int r = op(3, 2); // 5
op = Sub;
r = op(3, 2); // 1Built-in generic delegate (ใช้ตัวพวกนี้แทน — ไม่ต้องประกาศใหม่):
| Delegate | Signature |
|---|---|
Action | no param, void return |
Action<T> | 1 param, void return |
Action<T1, T2> | 2 param, void return |
Func<TResult> | no param, return TResult |
Func<T, TResult> | 1 param, return TResult |
Func<T1, T2, TResult> | 2 param + return |
Predicate<T> | T → bool |
ตัวอย่าง:
csharp
Func<int, int, int> add = Add;
Action<string> log = Console.WriteLine;
Predicate<int> isEven = n => n % 2 == 0;💡
Predicate<T>ทำงานเหมือนFunc<T, bool>ทุกอย่าง (รับ T คืน bool) — เจอบ่อยใน method เก่าอย่างList<T>.Find()/List<T>.FindAll()เท่านั้น งานใหม่ใช้Func<T, bool>ก็พอ ไม่ต้องเลือกPredicate<T>เอง
2. Lambda Expression
csharp
// แทน method ที่ assign ให้ delegate
Func<int, int> square = x => x * x;
Func<int, int, int> add = (a, b) => a + b;
// statement body (multi-line)
Func<int, int> abs = x =>
{
if (x < 0) return -x;
return x;
};
// ไม่มี parameter
Action greet = () => Console.WriteLine("Hello!");Lambda = anonymous method (ฟังก์ชันที่ไม่มีชื่อ) — มองเห็นตัวแปรของฟังก์ชันแม่ (outer) ได้ → กลไกนี้เรียก closure (= ฟังก์ชันที่จำตัวแปรจาก scope ภายนอกไว้ — ยึด reference ไม่ใช่ copy ค่า)
csharp
int factor = 10;
Func<int, int> multiply = x => x * factor;
Console.WriteLine(multiply(5)); // 50
factor = 100;
Console.WriteLine(multiply(5)); // 500 — closure ยึดตัวแปรจริง (reference) ไม่ใช่ค่า ณ ตอนสร้าง⚠️ Closure ใน
forloop = จุดพลาดคลาสสิก (pitfall) ที่ยังเกิดใน ทุก C# versioncsharpvar actions = new List<Action>(); for (int i = 0; i < 3; i++) actions.Add(() => Console.WriteLine(i)); actions.ForEach(a => a()); // ❌ พิมพ์ออกมา: 3, 3, 3 — ไม่ใช่ 0, 1, 2! // เพราะทุก lambda ยึด `i` ตัวเดียวกัน (reference) และตอน invoke ค่า i = 3 แล้ววิธีแก้ — capture ด้วยตัวแปรแยก:
csharpfor (int i = 0; i < 3; i++) { int captured = i; // ตัวแปรใหม่ต่อรอบ actions.Add(() => Console.WriteLine(captured)); } // ✅ พิมพ์: 0, 1, 2 ตามคาดหมายเหตุ: C# 5 แก้เฉพาะ
foreachให้ตัวแปรใน loop เป็นแบบ per-iteration อัตโนมัติ — แต่forloop ยังแชร์iตัวเดียวทุก version
3. Event — Pub/Sub pattern ของ C#
event คือกลไก publish/subscribe ในตัวของ C# — class หนึ่ง "ประกาศ" event (เช่นปุ่มถูกคลิก) แล้วผู้สนใจ "subscribe" ด้วย += เพื่อให้ถูกเรียกเมื่อเกิดเหตุการณ์ สร้างบน delegate:
csharp
public class Button
{
// event ที่ subscriber subscribe ได้
public event EventHandler? Clicked;
public void Press()
{
// notify ทุก subscriber
Clicked?.Invoke(this, EventArgs.Empty);
}
}
var btn = new Button();
btn.Clicked += (sender, args) => Console.WriteLine("clicked!");
btn.Clicked += LogClick; // method group
btn.Press(); // call ทั้งสอง handler
void LogClick(object? sender, EventArgs e) => Console.WriteLine("logged");event = delegate ที่ "subscribe เท่านั้น" (caller จาก outside invoke ตรง ๆ ไม่ได้)
4. LINQ — Query syntax
LINQ คือจุดเด่นของ C# — เขียน query กับ collection ได้ในตัวภาษา (filter = กรอง, map = แปลง, group = จัดกลุ่ม)
LINQ มี 2 รูปแบบ: query syntax (คล้าย SQL from...where...select) และ method syntax (ต่อ method) ทั้งคู่ให้ผลเหมือนกัน:
💡 ใน .NET 6+ ที่เปิด
ImplicitUsings=enable(default) ไม่ต้องเขียนusing System.Linq;เพราะถูกเพิ่มอัตโนมัติ — แต่เขียนไว้ก็ไม่ผิด
csharp
using System.Linq;
var nums = new[] { 1, 2, 3, 4, 5, 6 };
// method syntax (พบบ่อยที่สุด)
var evens = nums.Where(n => n % 2 == 0).Select(n => n * 10);
// → [20, 40, 60]
// query syntax (SQL-like)
var evens2 = from n in nums
where n % 2 == 0
select n * 10;
// ทั้งสองให้ผลเหมือนกัน — compiler แปล query → methodLINQ = chain ของ extension method ที่ทุก IEnumerable<T> มี
5. LINQ Operators ที่ใช้บ่อย
LINQ มี operator ให้ครบทุกงาน — กรอง (Where), แปลง (Select), เรียง (OrderBy), จัดกลุ่ม (GroupBy), รวมผล (Sum, Count), ดึงตัวเดียว (First, Single) รวมตัวที่ใช้บ่อยไว้ด้านล่าง:
Filter / Transform
csharp
nums.Where(n => n > 0) // filter
.Select(n => n * 2) // map
.SelectMany(n => new[] {n, -n}) // flat map (แปลงแล้วรวม nested collection ให้แบน)
.Distinct() // unique
.OrderBy(n => n) // sort ascending
.OrderByDescending(n => n)
.Reverse()
.Take(5)
.Skip(2)
.TakeWhile(n => n < 10)
.SkipWhile(n => n < 5);
// `ThenBy` ใช้สำหรับ secondary sort — ต้องมีฟิลด์ที่จัดเรียงได้ เช่น string มี Length
string[] words = ["apple", "kiwi", "banana"];
words.OrderBy(w => w[0]) // primary sort (ตัวอักษรแรก)
.ThenBy(w => w.Length); // secondary sort (ความยาว)Aggregate
csharp
nums.Count();
nums.Sum();
nums.Average();
nums.Min();
nums.Max();
nums.Aggregate((acc, n) => acc + n); // custom fold
nums.Aggregate("", (acc, n) => acc + n); // seed + foldFind element
csharp
nums.First(); // throw ถ้า empty
nums.FirstOrDefault(); // default(T) ถ้า empty
nums.FirstOrDefault(n => n > 100);// คืน 0 (default ของ int) ถ้าไม่เจอ
nums.Single(); // ต้องมีเพียง 1
nums.Last();
nums.ElementAt(2);Check existence
csharp
nums.Any(); // มีอย่างน้อย 1
nums.Any(n => n > 100);
nums.All(n => n > 0); // ทุกตัว pass
nums.Contains(5);Set operations
csharp
a.Union(b);
a.Intersect(b);
a.Except(b);
a.Concat(b); // appendGroup + Join
csharp
record User(int Id, string Name, int Age);
record Post(int Id, int AuthorId, string Title);
User[] users =
[
new(1, "Alice", 30),
new(2, "Bob", 25),
new(3, "Carol", 30),
];
// GroupBy → IGrouping<TKey, T>
var byAge = users.GroupBy(u => u.Age);
foreach (var grp in byAge)
{
Console.WriteLine($"Age {grp.Key}: {grp.Count()}");
foreach (var u in grp) Console.WriteLine($" {u.Name}");
}
// Join (inner join)
Post[] posts =
[
new(10, 1, "Hello"),
new(11, 2, "World"),
];
var query = users.Join(
posts,
u => u.Id, // user key
p => p.AuthorId, // post key
(u, p) => new { u.Name, p.Title }
);
// GroupJoin (left join semantics)
var query2 = users.GroupJoin(
posts,
u => u.Id,
p => p.AuthorId,
(u, ps) => new { User = u, Posts = ps.ToList() }
);Conversion
csharp
nums.ToList();
nums.ToArray();
nums.ToDictionary(n => n, n => n * 2); // ⚠️ throw ArgumentException ถ้า key ซ้ำ
// ถ้ามี duplicate ให้ GroupBy ก่อน:
// nums.GroupBy(n => n).ToDictionary(g => g.Key, g => g.First() * 2);
nums.ToHashSet();
nums.AsEnumerable();
nums.Cast<object>();
nums.OfType<int>(); // filter by type6. Deferred Execution (การเลื่อนทำงาน) — สำคัญมาก
เลื่อนทำงาน = LINQ query ยังไม่ทำงานทันทีตอนเขียน แต่รอจนเริ่มวน (iterate) ผลลัพธ์จริงค่อยทำ
สำหรับ LINQ to Object (in-memory) คือเก็บ chain ของ delegate ไว้ แล้วค่อยรันตอน iterate
ส่วน LINQ to SQL/EF ทำงานต่างออกไป — มันเก็บ query ไว้เป็น expression tree (โครงสร้างข้อมูลที่แสดง "logic" ของ query แทนที่จะรันโค้ดจริง) แล้วให้ EF แปล expression tree นั้นเป็นคำสั่ง SQL ก่อนส่งไปฐานข้อมูล (รายละเอียดเชิงลึกอยู่ในเล่ม dotnet/ — ตอนนี้รู้แค่ว่ามันแปลได้ก็พอ)
csharp
var query = nums.Where(n => { Console.WriteLine($"check {n}"); return n > 2; });
// query ยังไม่ execute! แค่เก็บ plan ไว้
foreach (var n in query) // ← เริ่มทำงานจริงตรงนี้
Console.WriteLine($"got {n}");ผลข้างเคียง: ถ้า source เปลี่ยน → query ผลเปลี่ยน
csharp
var list = new List<int> { 1, 2, 3 };
var q = list.Where(n => n > 1);
list.Add(99);
Console.WriteLine(string.Join(",", q)); // 2,3,99 — เพราะ q execute ตอน iterateถ้าต้องการ snapshot — .ToList() ทันที
7. Lazy + Streaming
LINQ ทำ streaming — ทำงานทีละ element:
csharp
// File.ReadLines อ่านไฟล์แบบ lazy ทีละบรรทัด (ไม่ load ทั้งไฟล์)
var found = File.ReadLines("huge.log") // IEnumerable<string>
.Where(l => l.Contains("ERROR"))
.Take(10) // หยุดเมื่อเจอ 10 ตัว
.ToList();อ่านไฟล์แค่ส่วนที่จำเป็น — ไม่ load ทั้งไฟล์ เหมาะกับไฟล์ขนาดใหญ่
8. LINQ to Object vs LINQ to SQL/EF
EF Core (Entity Framework Core — library เชื่อมโค้ด C# กับฐานข้อมูล) ช่วยให้เขียน LINQ แล้วมันแปลงเป็น SQL ให้อัตโนมัติ — สอนละเอียดในส่วน dotnet/ ตอนนี้แค่รู้ว่า LINQ ใช้กับ database ได้ด้วย แต่มีข้อจำกัดบางอย่าง
csharp
// LINQ to Object (in-memory)
var users = list.Where(u => u.Age > 18);
// LINQ to Entities (EF Core) — LINQ แปลงเป็น SQL อัตโนมัติ
var users2 = dbContext.Users.Where(u => u.Age > 18);
// EF แปลง → SELECT * FROM Users WHERE Age > 18ใน EF Core, LINQ ถูกแปล (translate) เป็น SQL — บาง expression แปลไม่ได้:
csharp
// ❌ ไม่ทำงานใน EF — ไม่มี SQL equivalent
db.Users.Where(u => MyHelper(u.Name))
.ToList();
// ✅ ทำใน memory หลัง query
db.Users.ToList()
.Where(u => MyHelper(u.Name)); // — แต่ load ทั้ง table!
// ✅ ดีกว่า — translate ได้
db.Users.Where(u => u.Name.StartsWith("A"))
.ToList();ดูเพิ่มใน dotnet/02-database-and-ef-core.md
9. Performance tips
LINQ สะดวกแต่มีกับดัก performance — เช่น iterate ซ้ำ (N+1), เรียก .ToList() ก่อนเวลาอันควร, หรือลืมว่า LINQ เป็นการ "เลื่อนทำงาน" ส่วนนี้รวมจุดที่ควรระวังและวิธีเขียนให้เร็ว:
csharp
// (สมมติ User มี ManagerId — id ของหัวหน้า; null ถ้าไม่มีหัวหน้า)
record Employee(int Id, string Name, int? ManagerId);
// ❌ N+1 problem — วน users N ครั้ง แต่ query ซ้ำ N ครั้งข้างในด้วย = N+1 รอบทั้งหมด
foreach (var u in employees)
if (employees.Where(x => x.ManagerId == u.Id).Any())
Console.WriteLine($"{u.Name} is a manager");
// ✅ build lookup ก่อน — O(n) รวม
var managerIds = employees.Where(u => u.ManagerId.HasValue)
.Select(u => u.ManagerId!.Value)
.ToHashSet();
foreach (var u in employees)
if (managerIds.Contains(u.Id))
Console.WriteLine($"{u.Name} is a manager");csharp
// ❌ .Count() ทุกครั้ง
var list = source.Where(...);
if (list.Count() > 0) // iterate ครบทุก element เพื่อหา count
foreach (var x in list) // iterate ซ้ำอีกรอบ — รวม 2 รอบ
...
// ✅
var listCached = source.Where(...).ToList();
if (listCached.Count > 0)
foreach (var x in listCached) ...
// ✅ ดีกว่า — .Any() แทน .Count() > 0
if (source.Any(...)) { ... }
// 💡 ถ้า variable เป็น List<T> หรือ ICollection<T> ใช้ .Count > 0 (property) ได้ — O(1) และไม่ allocate
// ใช้ .Any() เมื่อ type เป็น IEnumerable<T> ที่ไม่รู้ว่าเป็น collection จริงหรือเปล่าcsharp
// ❌ multiple enumeration
public void Process(IEnumerable<User> users)
{
foreach (var u in users) ...
foreach (var u in users) ... // iterate 2 ครั้ง — ถ้า source = lazy DB query → 2 query
}
// ✅
public void Process(IReadOnlyList<User> users) { ... }
// หรือ
public void Process(IEnumerable<User> users)
{
var cached = users.ToList();
// ...
}10. Static Lambda — ลด allocation (การจองหน่วยความจำ)
lambda ที่ capture (ยืมใช้) ตัวแปรภายนอกจะสร้าง "closure" object ใหม่ทุกครั้งที่ถูกเรียก
ถ้า lambda ไม่จำเป็นต้อง capture อะไรเลย ใส่ static ไว้หน้า lambda ได้ (C# 9+) — compiler จะบังคับว่าห้าม capture ตัวแปรภายนอกเด็ดขาด (ถ้าเผลอ capture จะ error ตอน compile)
ทำไมเรื่องนี้ถึงสำคัญ — เพราะทุก closure ที่สร้างใหม่คือ allocation (การจองหน่วยความจำบน heap คือพื้นที่หน่วยความจำที่ .NET ใช้เก็บ object) และยิ่งจองบ่อย garbage collector (ตัวเก็บกวาดหน่วยความจำที่ไม่ใช้แล้ว) ก็ยิ่งทำงานหนัก ทำให้โปรแกรมช้าลงถ้าเรียก lambda นั้นบ่อย ๆ (เช่นในลูป):
csharp
int threshold = 5;
// ❌ allocate closure ทุกครั้ง — capture `threshold`
nums.Where(n => n > threshold).ToList();
// ✅ static lambda (C# 9+) — บังคับว่าห้าม capture จากภายนอก
nums.Where(static n => n > 0).ToList();static ที่ lambda = "ห้าม capture outer" → ไม่ allocate
ประโยชน์ชัดเจนกว่าตอนเขียนอยู่ใน class ที่จะเผลอ capture this:
csharp
public class Filter
{
private int _min;
// ❌ lambda นี้ capture `this` (เพราะใช้ _min) → allocate closure ทุกครั้ง
public IEnumerable<int> Apply(IEnumerable<int> src) => src.Where(n => n > _min);
// ✅ ใช้ static lambda + ส่งค่าเข้าไป — compiler บังคับให้ไม่เผลอ capture
public IEnumerable<int> ApplyPositive(IEnumerable<int> src) => src.Where(static n => n > 0);
}11. PLINQ — Parallel LINQ (LINQ ที่ทำงานขนานหลาย core)
⏭️ โซนขั้นสูง — มือใหม่ข้ามได้
PLINQ เป็นเทคนิคเพิ่ม performance สำหรับงาน CPU หนัก ๆ ที่ใช้น้อยในงานทั่วไป มือใหม่ข้ามได้ก่อน
ถ้างานหนักและแต่ละ item (รายการ) ทำแยกกันได้ PLINQ ช่วยกระจายงานไปหลาย core (แกนประมวลผล) อัตโนมัติ แค่เติม .AsParallel()
แต่คุ้มเฉพาะงานที่ CPU หนักจริง — งานเบาจะช้าลงเพราะ overhead (ต้นทุนแฝงในการแบ่งงาน):
csharp
var result = nums.AsParallel()
.Where(IsPrime)
.Select(n => n * 2)
.ToList();ใช้กับ CPU-bound query บน data ใหญ่ — AsParallel() แตก data ไปหลาย CPU core (แกนประมวลผล)
12. Common Pitfalls (จุดพลาดบ่อย)
.First()ทั้งที่ collection ว่าง (empty) → InvalidOperationException → ใช้.FirstOrDefault()- Multiple enumeration (วนซ้ำหลายรอบ) ของ
IEnumerable<T>ที่ lazy (ทำงานแบบเลื่อน) → query ทำงานซ้ำหลายครั้ง - แก้ source (ต้นทางข้อมูล) ขณะกำลังวน (iterate) → exception
- GroupBy แล้ว
.Count()ในลูป — เพราะเป็น deferred (เลื่อนทำงาน) → เก็บผลไว้ (cache) ด้วย.ToList() Whereที่มี side effect (ผลข้างเคียง เช่นเขียน log) —.Where(x => { Log(x); return ...; })→ debug งง- LINQ ใน hot path (โค้ดที่รันถี่) — foreach + ลูปเขียนเองอาจเร็วกว่า LINQ ได้ในบางงาน แต่ตัวเลขต่างกันมากตามสถานการณ์ (.NET 8+ optimize LINQ ดีขึ้นมาก) อย่าเชื่อตัวเลขลอย ๆ — วัดจริงด้วย
Stopwatch(ดูวิธีใช้ใน บทที่ 7 หัวข้อ checkpoint) ก่อนสรุปว่าอันไหนเร็วกว่า
🛠️ Checkpoint 9 — ลงมือทำ
ใช้ data:
csharp
record User(int Id, string Name, int Age, string City);
record Post(int Id, int AuthorId, string Title, int Likes);- หา top 3 city ที่มี user มากที่สุด
- join user + post → group by author → คืน
(Name, TotalPosts, TotalLikes)เรียงตาม TotalLikes desc - user ที่อายุ 18-30 + อยู่ใน Bangkok + มี post อย่างน้อย 1 อัน
- count post ที่ title มีคำว่า "ai" (case-insensitive)
- ลอง same query แบบ method syntax vs query syntax — เลือกที่อ่านง่ายกว่า
สรุปบทที่ 9
- Delegate = pointer ไปยัง method;
Func<>,Action<>,Predicate<>ใช้แทนได้ - Lambda = anonymous method + closure
- Event = delegate ที่ "subscribe only"
- LINQ = chain ของ extension method บน
IEnumerable<T> - Deferred execution — query สั่งงาน แต่ run ตอน iterate
- LINQ to Object (RAM) vs LINQ to Entities (SQL translate)
- ระวัง multiple enumeration + .Count() ใน hot path