1.9 — Mở rộng và đào sâu
Những chủ đề nối tiếp Module 1, kèm điều kiện để đáng học tiếp. Điểm nhấn: hai trong số chúng — LINQ và pattern matching — nên học sớm vì bạn sẽ gặp chúng trong mọi codebase .NET hiện đại và chúng làm code ngắn hơn đáng kể. Hai cái còn lại — đệ quy và bất biến vòng lặp — hữu ích nhưng ít dùng trong code nghiệp vụ hàng ngày. Và một lời cảnh báo cụ thể về đệ quy: trong backend .NET, nó hiếm khi là lựa chọn đúng, vì độ sâu đệ quy không kiểm soát được sẽ gây StackOverflowException — một lỗi không bắt được bằng try/catch và giết luôn tiến trình.
Mục tiêu bài học
Sau bài này bạn có thể:
- Biết những chủ đề tiếp theo và thứ tự nên học.
- Dùng LINQ cho các thao tác trên tập hợp.
- Dùng pattern matching thay chuỗi
ifdài. - Biết vì sao đệ quy ít phù hợp với backend.
Nội dung bài học
1.9.1 — LINQ: học sớm
Nên học ngay sau Module 1. Bạn sẽ gặp nó trong mọi dự án .NET.
// Viết tay
var vipCustomers = new List<Customer>();
foreach (var c in customers)
{
if (c.TotalSpent > 100_000_000m)
vipCustomers.Add(c);
}
vipCustomers.Sort((a, b) => b.TotalSpent.CompareTo(a.TotalSpent));
// LINQ — cùng kết quả
var vipCustomers = customers
.Where(c => c.TotalSpent > 100_000_000m)
.OrderByDescending(c => c.TotalSpent)
.ToList();
Năm thao tác dùng nhiều nhất:
| Thao tác | Việc làm |
|---|---|
Where | Lọc theo điều kiện |
Select | Biến đổi từng phần tử |
OrderBy / OrderByDescending | Sắp xếp |
GroupBy | Nhóm theo khoá |
Sum / Count / Average | Tính tổng hợp |
Cái bẫy quan trọng nhất — deferred execution:
var query = customers.Where(c => c.TotalSpent > 100_000_000m);
customers.Add(new Customer { TotalSpent = 200_000_000m });
var count = query.Count(); // TÍNH LẠI tại đây -> bao gồm cả phần tử vừa thêm
LINQ không chạy ngay khi bạn viết Where — nó chỉ mô tả truy vấn. Việc chạy xảy ra khi bạn duyệt kết quả hoặc gọi ToList, Count, First. Gọi hai lần là chạy hai lần.
// Dùng ToList() khi cần kết quả CỐ ĐỊNH và dùng nhiều lần
var vipCustomers = customers.Where(...).ToList();
Đây cũng là nền tảng để hiểu EF Core, vì truy vấn database dùng chính cơ chế này (bài 13.6).
1.9.2 — Pattern matching: học sớm
// Chuỗi if dài
string Rank(decimal totalSpent)
{
if (totalSpent >= 500_000_000m) return "Kim cương";
else if (totalSpent >= 100_000_000m) return "Vàng";
else if (totalSpent >= 10_000_000m) return "Bạc";
else return "Thường";
}
// Switch expression — ngắn hơn và dễ đọc hơn
string Rank(decimal totalSpent) => totalSpent switch
{
>= 500_000_000m => "Kim cương",
>= 100_000_000m => "Vàng",
>= 10_000_000m => "Bạc",
_ => "Thường"
};
Lợi ích không chỉ là ngắn hơn: trình biên dịch cảnh báo nếu bạn bỏ sót trường hợp với kiểu enum, điều mà chuỗi if không làm được.
// Khớp theo kiểu và thuộc tính
decimal CalculateShippingFee(Order order) => order switch
{
{ Total: > 1_000_000m } => 0,
{ Region: "HCM" or "HN" } => 20_000m,
{ WeightKg: > 10 } => 50_000m,
_ => 35_000m
};
Lưu ý: thứ tự các nhánh quan trọng — nhánh đầu tiên khớp sẽ được chọn. Đặt điều kiện hẹp nhất lên trước (bài 1.3).
1.9.3 — Đệ quy: học để hiểu, ít dùng để viết
// Đệ quy — đúng nhưng nguy hiểm trong backend
decimal SumCategoryTree(Category category)
{
decimal total = category.Value;
foreach (var child in category.Children)
total += SumCategoryTree(child); // gọi chính nó
return total;
}
Vấn đề cụ thể: nếu dữ liệu có vòng lặp (A là con của B, B là con của A — chuyện xảy ra thật khi dữ liệu bị nhập sai), hàm này chạy vô hạn cho tới khi ném StackOverflowException.
Và đây là điểm nguy hiểm nhất: StackOverflowException không bắt được bằng try/catch trong .NET. Nó giết luôn tiến trình, kéo theo mọi request khác đang chạy trên cùng process.
// Dùng stack tường minh — kiểm soát được, và phát hiện vòng lặp
decimal SumCategoryTree(Category root)
{
decimal total = 0;
var visited = new HashSet<int>();
var stack = new Stack<Category>();
stack.Push(root);
while (stack.Count > 0)
{
var category = stack.Pop();
if (!visited.Add(category.Id))
throw new InvalidOperationException($"Danh mục {category.Id} tạo vòng lặp");
total += category.Value;
foreach (var child in category.Children)
stack.Push(child);
}
return total;
}
Dài hơn, nhưng nó phát hiện được vòng lặp và không bao giờ làm tràn stack.
Với dữ liệu cây trong database, thường tốt hơn nữa là để database làm việc đó bằng recursive CTE (bài 12.4).
1.9.4 — Immutability
// record — bất biến, so sánh theo GIÁ TRỊ
public record Address(string HouseNumber, string Street, string District, string City);
var a1 = new Address("12", "Nguyễn Huệ", "1", "HCM");
var a2 = new Address("12", "Nguyễn Huệ", "1", "HCM");
Console.WriteLine(a1 == a2); // True — so sánh giá trị
// Muốn đổi thì tạo bản mới
var a3 = a1 with { District = "3" };
Hai lợi ích thực tế: đối tượng bất biến an toàn khi dùng đa luồng (không ai đổi được nó giữa chừng), và code dễ suy luận hơn vì giá trị không thay đổi sau khi tạo.
Đây là nền tảng cho value object trong DDD (bài 16.14).
1.9.5 — Bất biến vòng lặp
Một công cụ tư duy để viết vòng lặp đúng: điều gì luôn đúng ở mỗi lần lặp?
// Bất biến: sau vòng lặp thứ i, "max" là giá trị lớn nhất trong amounts[0..i]
decimal max = amounts[0];
for (int i = 1; i < amounts.Count; i++)
{
if (amounts[i] > max)
max = amounts[i];
}
// Kết thúc: max là giá trị lớn nhất toàn danh sách
Phát biểu được bất biến nghĩa là bạn hiểu vòng lặp đang làm gì. Nó cũng giúp bắt hai lỗi phổ biến: khởi tạo sai (bắt đầu từ i = 0 sẽ so sánh phần tử đầu với chính nó — vô hại ở đây nhưng không phải lúc nào cũng vậy) và điều kiện dừng sai (<= thay vì < gây IndexOutOfRangeException).
Ít dùng trong code nghiệp vụ hàng ngày, nhưng rất hữu ích khi viết thuật toán hoặc khi một vòng lặp cho kết quả sai mà bạn không hiểu vì sao.
1.9.6 — Thứ tự nên học
| Ưu tiên | Chủ đề | Vì sao |
|---|---|---|
| Cao | LINQ | Gặp trong mọi codebase .NET |
| Cao | Pattern matching | Ngắn hơn, trình biên dịch kiểm tra giúp |
| Trung bình | record và immutability | Nền tảng cho value object |
| Thấp | Đệ quy | Hiếm dùng, và thường có cách an toàn hơn |
| Thấp | Bất biến vòng lặp | Công cụ tư duy, không phải cú pháp |
Sau Module 1, bước tiếp theo trong lộ trình là Module 2 — Computer Science Basics.
1.9.7 — Rà lại code của bạn
Danh sách rà soát sau Module 1
- •Dùng được năm thao tác LINQ cơ bản.
- •Hiểu deferred execution và biết khi nào cần ToList.
- •Dùng switch expression thay chuỗi if dài.
- •Biết thứ tự nhánh trong pattern matching là quan trọng.
- •Không dùng đệ quy cho dữ liệu có thể sâu hoặc có vòng lặp.
- •Biết StackOverflowException không bắt được bằng try catch.
- •Dùng record cho đối tượng chỉ mang giá trị.
Bài tập áp dụng
Bài 1 — Viết lại bằng LINQ
Lấy ba vòng lặp trong code của bạn và viết lại bằng LINQ. So sánh số dòng.
Tiêu chí hoàn thành: cả ba bản LINQ cho đúng kết quả cũ, và bạn chỉ ra được ít nhất một vòng lặp mà LINQ không làm tốt hơn.
Gợi ý và lời giải — Bài 1
Gợi ý. Vòng lặp nào chỉ lọc, biến đổi hoặc gom nhóm thì LINQ gọn hơn hẳn. Vòng lặp nào có nhiều việc khác nhau bên trong thì chưa chắc.
Lời giải — ba vòng lặp điển hình:
// 1. LỌC — vòng lặp
var leadDangXuLy = new List<Lead>();
foreach (var lead in danhSachLead)
{
if (lead.TrangThai == TrangThaiLead.DangXuLy)
leadDangXuLy.Add(lead);
}
// 1. LỌC — LINQ
var leadDangXuLy = danhSachLead
.Where(l => l.TrangThai == TrangThaiLead.DangXuLy)
.ToList();
6 dòng -> 3 dòng
// 2. BIẾN ĐỔI — vòng lặp
var ten = new List<string>();
foreach (var lead in danhSachLead)
ten.Add(lead.HoTen.ToUpper());
// 2. BIẾN ĐỔI — LINQ
var ten = danhSachLead.Select(l => l.HoTen.ToUpper()).ToList();
// 3. GOM NHÓM VÀ TÍNH TỔNG — vòng lặp
var tongTheoNhanVien = new Dictionary<int, decimal>();
foreach (var lead in danhSachLead)
{
if (tongTheoNhanVien.ContainsKey(lead.NhanVienId))
tongTheoNhanVien[lead.NhanVienId] += lead.GiaTri;
else
tongTheoNhanVien[lead.NhanVienId] = lead.GiaTri;
}
// 3. GOM NHÓM VÀ TÍNH TỔNG — LINQ
var tongTheoNhanVien = danhSachLead
.GroupBy(l => l.NhanVienId)
.ToDictionary(g => g.Key, g => g.Sum(l => l.GiaTri));
9 dòng -> 3 dòng
Và đây là phần quan trọng hơn: một vòng lặp mà LINQ KHÔNG làm tốt hơn.
// Vòng lặp làm NHIỀU việc khác nhau cùng lúc
decimal tong = 0;
int soLoi = 0;
var canXemLai = new List<Lead>();
foreach (var lead in danhSachLead)
{
if (lead.GiaTri < 0)
{
soLoi++;
_log.LogWarning("Lead {Id} có giá trị âm", lead.Id);
continue;
}
tong += lead.GiaTri;
if (lead.GiaTri > 1_000_000_000)
canXemLai.Add(lead);
}
Viết bằng LINQ: cần BA lần duyệt (một cho tổng, một cho đếm lỗi,
một cho danh sách xem lại)
-> dài hơn, chậm hơn, và khó đọc hơn
Vòng lặp: một lần duyệt, mọi thứ rõ ràng.
Ba trường hợp nên giữ vòng lặp:
| Tình huống | Vì sao |
|---|---|
| Làm nhiều việc khác nhau trong một lần duyệt | LINQ buộc tách thành nhiều lần duyệt |
| Cần ghi log hoặc xử lý lỗi cho từng phần tử | Thân LINQ không phải chỗ cho tác dụng phụ |
Cần break sớm với điều kiện phức tạp | TakeWhile không phải lúc nào cũng diễn đạt được |
Và một điều đáng nhớ về LINQ: nó diễn đạt "cái gì", không phải "làm thế nào".
// Vòng lặp: nói CÁCH LÀM
foreach (var lead in danhSachLead)
if (lead.TrangThai == TrangThaiLead.DangXuLy)
ketQua.Add(lead);
// LINQ: nói CÁI GÌ
danhSachLead.Where(l => l.TrangThai == TrangThaiLead.DangXuLy)
Người đọc hiểu ngay ý định ở bản LINQ mà không phải đọc hết thân vòng lặp để suy ra. Đó là lợi ích thật, lớn hơn cả việc tiết kiệm mấy dòng.
Bài 2 — Chứng minh deferred execution
Tạo một truy vấn Where, thêm phần tử vào danh sách gốc, rồi gọi Count và giải thích kết quả.
Tiêu chí hoàn thành: bạn chạy và thấy con số thay đổi, và biết cách "chốt" kết quả lại khi không muốn nó thay đổi.
Gợi ý và lời giải — Bài 2
Gợi ý. Where không chạy lúc bạn viết nó. Nó chạy lúc bạn đọc kết quả.
Lời giải:
var ds = new List<int> { 1, 2, 3 };
var truyVan = ds.Where(x => x > 1); // chưa chạy gì cả
Console.WriteLine($"Count trước khi thêm : {truyVan.Count()}");
ds.Add(10);
ds.Add(20);
Console.WriteLine($"Count sau khi thêm : {truyVan.Count()}");
var chot = ds.Where(x => x > 1).ToList(); // CHỐT lại tại đây
ds.Add(30);
Console.WriteLine($"Count sau khi ToList : {chot.Count}");
Kết quả chạy trên .NET 9.0.203:
Count trước khi thêm : 2
Count sau khi thêm : 4
Count sau khi ToList : 4
Ba con số, ba điều cần hiểu:
1. 2 — lúc này danh sách có {1, 2, 3}, hai phần tử lớn hơn 1.
2. 4 — cùng một biến truyVan, không gán lại, nhưng kết quả đổi.
truyVan KHÔNG chứa kết quả.
Nó chứa CÔNG THỨC: "lấy những phần tử của ds mà lớn hơn 1".
Mỗi lần gọi Count(), công thức đó chạy LẠI trên ds hiện tại.
{1,2,3,10,20} -> bốn phần tử lớn hơn 1
3. 4 sau khi thêm 30 — vì ToList() đã sao chép kết quả ra một danh sách mới.
ToList(), ToArray(), ToDictionary() -> CHẠY NGAY và chốt kết quả
Where(), Select(), OrderBy() -> chỉ mô tả, chưa chạy
Vì sao điều này quan trọng — hai tình huống thật:
1. Truy vấn chạy nhiều lần mà không ai ngờ.
var leadLon = danhSachLead.Where(l => l.GiaTri > 1_000_000_000);
Console.WriteLine($"Có {leadLon.Count()} lead lớn"); // duyệt lần 1
foreach (var l in leadLon) Xuly(l); // duyệt lần 2
var dau = leadLon.First(); // duyệt lần 3
Ba lần duyệt toàn bộ danh sách, cho một việc lẽ ra chỉ cần một lần.
Với danh sách trong bộ nhớ: lãng phí.
Với truy vấn database (IQueryable): BA lần gọi database.
var leadLon = danhSachLead.Where(l => l.GiaTri > 1_000_000_000).ToList(); // một lần
2. Biến vòng lặp bị "bắt" vào công thức.
var cacTruyVan = new List<IEnumerable<int>>();
for (int i = 0; i < 3; i++)
cacTruyVan.Add(ds.Where(x => x > i)); // i được ghi nhớ, không phải giá trị của nó
// Tới khi chạy, i đã là 3 với vòng for kiểu cũ trong một số ngôn ngữ khác
Trong C# hiện đại, i của for được bắt đúng giá trị từng vòng, nên ví dụ này an toàn — nhưng nó cho thấy vì sao "công thức chạy sau" là điều cần nhớ khi đọc code.
Quy tắc thực dụng:
Dùng kết quả MỘT lần, ngay tại chỗ -> để nguyên, không ToList
Dùng nhiều lần, hoặc trả ra ngoài -> ToList() để chốt
Trả về từ một phương thức public -> ToList(), vì người gọi không biết
rằng nó chưa chạy
Quy tắc cuối tránh được một lỗi khó chịu: một phương thức trả về IEnumerable<T> chưa chạy, và người gọi duyệt nó sau khi DbContext đã bị huỷ — lúc đó chương trình ném lỗi ở một chỗ hoàn toàn không liên quan tới nguyên nhân.
Bài 3 — Đệ quy có vòng lặp
Tạo dữ liệu cây có vòng lặp và chạy hàm đệ quy. Quan sát điều gì xảy ra với tiến trình.
Tiêu chí hoàn thành: bạn thấy tiến trình chết, và hiểu vì sao lỗi này không bắt được bằng try/catch.
Gợi ý và lời giải — Bài 3
Gợi ý. "Cây" trong dữ liệu thật đôi khi không phải cây — nếu A là cha của B, B là cha của C, và C là cha của A.
Lời giải:
// Không phải cây: A -> B -> C -> A
var nut = new Dictionary<string, List<string>>
{
["A"] = ["B"], ["B"] = ["C"], ["C"] = ["A"],
};
int sau = 0;
void Duyet(string ten)
{
sau++;
if (sau % 10_000 == 0) Console.WriteLine($" độ sâu = {sau:N0}");
foreach (var con in nut[ten]) Duyet(con);
}
Console.WriteLine("Bắt đầu duyệt cây có vòng lặp...");
Duyet("A");
Console.WriteLine("Không bao giờ tới đây");
Kết quả chạy trên .NET 9.0.203, Linux:
Bắt đầu duyệt cây có vòng lặp...
độ sâu = 10.000
độ sâu = 20.000
độ sâu = 30.000
độ sâu = 40.000
Stack overflow.
at Program.<<Main>$>g__Duyet|0_1(System.String, ...)
at Program.<<Main>$>g__Duyet|0_1(System.String, ...)
...
echo $?
134
Ba điều cần hiểu từ kết quả này:
1. Tiến trình bị huỷ, không phải ném ngoại lệ.
Exit code 134 = 128 + 6 = SIGABRT
-> runtime tự huỷ tiến trình
Dòng "Không bao giờ tới đây" không được in.
Và mọi code dọn dẹp trong finally cũng KHÔNG chạy.
2. try/catch không bắt được.
try
{
Duyet("A");
}
catch (StackOverflowException) // KHÔNG BAO GIỜ chạy vào đây
{
Console.WriteLine("Đã bắt được");
}
Từ .NET Framework 2.0 trở đi, StackOverflowException KHÔNG bắt được.
Lý do: khi stack đã đầy, runtime không còn chỗ để chạy chính khối catch.
-> cách an toàn duy nhất là huỷ tiến trình.
Đây là điều làm lỗi này nguy hiểm hơn các lỗi khác: một endpoint duy nhất có dữ liệu vòng lặp sẽ giết cả tiến trình, kéo theo mọi request khác đang chạy.
3. Độ sâu đạt được khoảng 40.000–50.000 lần gọi.
Stack mặc định 1 MB, mỗi khung gọi ở đây khoảng 20–30 byte
-> khoảng 40.000 lần gọi
Với hàm có nhiều biến cục bộ hơn, con số này nhỏ hơn nhiều.
Cách sửa — theo dõi những nút đã đi qua:
void Duyet(string ten, HashSet<string> daDi)
{
if (!daDi.Add(ten)) // Add trả về false nếu đã có
{
_log.LogWarning("Phát hiện vòng lặp tại {Ten}, bỏ qua", ten);
return;
}
foreach (var con in nut[ten])
Duyet(con, daDi);
}
Duyet("A", new HashSet<string>());
Hoặc bỏ đệ quy hoàn toàn, dùng một ngăn xếp tường minh:
var canDi = new Stack<string>();
var daDi = new HashSet<string>();
canDi.Push("A");
while (canDi.Count > 0)
{
var ten = canDi.Pop();
if (!daDi.Add(ten)) continue;
foreach (var con in nut[ten])
canDi.Push(con);
}
Không dùng stack của luồng -> không bao giờ tràn
-> giới hạn giờ là bộ nhớ heap, lớn hơn rất nhiều
-> và nếu hết heap thì ném OutOfMemoryException, BẮT ĐƯỢC
Ba chỗ dữ liệu "cây" thường có vòng lặp trong thực tế:
| Dữ liệu | Vòng lặp xuất hiện khi |
|---|---|
| Sơ đồ tổ chức (nhân viên → quản lý) | A quản lý B, B quản lý A do nhập nhầm |
| Danh mục sản phẩm lồng nhau | Ai đó kéo một danh mục vào chính con của nó |
| Phụ thuộc giữa các công việc | Việc A chờ B, B chờ A |
Cả ba đều là dữ liệu do người dùng nhập, nên không thể giả định rằng nó luôn hợp lệ. Cách phòng vững nhất là chặn ngay lúc ghi:
// Trước khi cho phép đặt quản lý, kiểm tra không tạo ra vòng lặp
public bool TaoRaVongLap(int nhanVienId, int quanLyMoiId)
{
var hienTai = quanLyMoiId;
var daDi = new HashSet<int>();
while (hienTai != 0 && daDi.Add(hienTai))
{
if (hienTai == nhanVienId) return true; // quay về chính mình
hienTai = _db.NhanVien.Find(hienTai)?.QuanLyId ?? 0;
}
return false;
}
Chặn lúc ghi tốt hơn chặn lúc đọc: dữ liệu sai không bao giờ vào được database, nên mọi đoạn code đọc nó về sau đều an toàn mà không phải tự bảo vệ.
Tự kiểm tra
Frequently asked questions
Deferred execution trong LINQ nghĩa là gì?
Truy vấn không chạy khi bạn viết Where mà chỉ mô tả việc cần làm. Nó chạy khi bạn duyệt kết quả hoặc gọi ToList, Count, First. Nên nếu dữ liệu gốc đổi giữa chừng, kết quả cũng đổi theo, và gọi hai lần là chạy hai lần.
Vì sao switch expression tốt hơn chuỗi if dài?
Ngắn hơn và dễ đọc hơn, nhưng quan trọng hơn là trình biên dịch cảnh báo khi bạn bỏ sót trường hợp với kiểu enum, điều mà chuỗi if không làm được.
Vì sao thứ tự nhánh trong pattern matching quan trọng?
Vì nhánh đầu tiên khớp sẽ được chọn, nên phải đặt điều kiện hẹp nhất lên trước. Đặt điều kiện rộng trước sẽ khiến các nhánh hẹp phía sau không bao giờ được chạy.
Vì sao đệ quy nguy hiểm trong backend .NET?
Vì nếu dữ liệu có vòng lặp hoặc quá sâu, hàm sẽ gây StackOverflowException. Lỗi này không bắt được bằng try catch trong .NET, nó giết luôn tiến trình và kéo theo mọi request khác đang chạy.
Dùng stack tường minh hơn gì so với đệ quy?
Nó kiểm soát được độ sâu, phát hiện được vòng lặp bằng cách ghi lại những nút đã thăm, và không bao giờ làm tràn stack của tiến trình.
Hai lợi ích thực tế của immutability là gì?
Đối tượng bất biến an toàn khi dùng đa luồng vì không ai đổi được nó giữa chừng, và code dễ suy luận hơn vì giá trị không thay đổi sau khi tạo.
Kết luận
Ba điều đáng nhớ nhất:
- LINQ và pattern matching nên học ngay — bạn gặp chúng ở mọi nơi.
- Deferred execution là cái bẫy lớn nhất của LINQ, và cũng là nền tảng của EF Core.
- Đệ quy hiếm khi là lựa chọn đúng trong backend — stack tường minh an toàn hơn.
Tham khảo
Điều hướng
- Bài trước: 1.7 — 7. Cấu Trúc Dữ Liệu Cơ Bản và Big-O
- Bài tiếp theo: 1.9 — Mini case study
- Về module: Trang mục lục