Skip to content

บทที่ 9 — Lambda + Delegate + LINQ

← บทที่ 8 | สารบัญ | บทที่ 10: Modern C# →

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);       // 1

Built-in generic delegate (ใช้ตัวพวกนี้แทน — ไม่ต้องประกาศใหม่):

DelegateSignature
Actionno 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 ใน for loop = จุดพลาดคลาสสิก (pitfall) ที่ยังเกิดใน ทุก C# version

csharp
var 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 ด้วยตัวแปรแยก:

csharp
for (int i = 0; i < 3; i++)
{
    int captured = i;   // ตัวแปรใหม่ต่อรอบ
    actions.Add(() => Console.WriteLine(captured));
}
// ✅ พิมพ์: 0, 1, 2 ตามคาด

หมายเหตุ: C# 5 แก้เฉพาะ foreach ให้ตัวแปรใน loop เป็นแบบ per-iteration อัตโนมัติ — แต่ for loop ยังแชร์ 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 → method

LINQ = 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 + fold

Find 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);                      // append

Group + 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 type

6. 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 (จุดพลาดบ่อย)

  1. .First() ทั้งที่ collection ว่าง (empty) → InvalidOperationException → ใช้ .FirstOrDefault()
  2. Multiple enumeration (วนซ้ำหลายรอบ) ของ IEnumerable<T> ที่ lazy (ทำงานแบบเลื่อน) → query ทำงานซ้ำหลายครั้ง
  3. แก้ source (ต้นทางข้อมูล) ขณะกำลังวน (iterate) → exception
  4. GroupBy แล้ว .Count() ในลูป — เพราะเป็น deferred (เลื่อนทำงาน) → เก็บผลไว้ (cache) ด้วย .ToList()
  5. Where ที่มี side effect (ผลข้างเคียง เช่นเขียน log).Where(x => { Log(x); return ...; }) → debug งง
  6. 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);
  1. หา top 3 city ที่มี user มากที่สุด
  2. join user + post → group by author → คืน (Name, TotalPosts, TotalLikes) เรียงตาม TotalLikes desc
  3. user ที่อายุ 18-30 + อยู่ใน Bangkok + มี post อย่างน้อย 1 อัน
  4. count post ที่ title มีคำว่า "ai" (case-insensitive)
  5. ลอง 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

→ บทที่ 10: Modern C#