4.3 - Inheritance và Polymorphism
Tóm tắt: Kế thừa tạo ra quan hệ chặt nhất có thể giữa hai class: lớp con phụ thuộc vào chi tiết cài đặt của lớp cha, và một thay đổi ở cha có thể phá vỡ mọi con. Vì thế câu hỏi đúng không phải "cái này kế thừa được không" mà "cái này có thật sự là một cái kia không, ở mọi tình huống?". Nguyên tắc thay thế Liskov phát biểu chính xác điều đó, và ví dụ hình vuông–hình chữ nhật cho thấy trực giác "là một" hay đánh lừa. Bài này cũng chỉ ra một cái bẫy ít ai biết: gọi phương thức
virtualtrong constructor khiến code của lớp con chạy trước khi thân constructor của lớp con kịp gán field.
Mục tiêu bài học
Sau bài này bạn có thể:
- Phân biệt
virtual/overridevớinew, và nói được cái nào thật sự đa hình. - Áp dụng nguyên tắc thay thế Liskov để quyết định có nên kế thừa không.
- Chọn giữa kế thừa và composition theo tiêu chí rõ ràng.
- Nhận ra cái bẫy gọi
virtualtrong constructor. - Dùng
sealedvà biết khi nào nó có ích.
Cần biết trước: bài 4.1 (constructor, property) và bài 4.2 (đóng gói). Bài tập 3 viết test theo cách của bài 0.4.
Nội dung bài học
4.3.1 — Kế thừa và đa hình
public abstract class Payment
{
public decimal Amount { get; }
protected Payment(decimal amount) => Amount = amount;
public abstract decimal CalculateFee(); // con BẮT BUỘC cài đặt
public virtual string Describe() => $"Thanh toán {Amount:n0}"; // con CÓ THỂ ghi đè
}
public class CardPayment : Payment
{
public CardPayment(decimal amount) : base(amount) { }
public override decimal CalculateFee() => Amount * 0.022m;
}
public class BankTransfer : Payment
{
public BankTransfer(decimal amount) : base(amount) { }
public override decimal CalculateFee() => 11_000m;
public override string Describe() => $"Chuyển khoản {Amount:n0}";
}Đa hình là chỗ này:
var payments = new List<Payment> { new CardPayment(1_000_000m), new BankTransfer(1_000_000m) };
foreach (var p in payments)
Console.WriteLine(p.CalculateFee()); // 22000, rồi 11000Vòng lặp không biết nó đang cầm loại nào. Phương thức đúng được chọn lúc chạy, dựa trên kiểu thật của object.
Cây kế thừa trong ví dụ:
4.3.2 — override và new: một cái đa hình, một cái không
public class Base
{
public virtual string Who() => "Base";
}
public class DerivedOverride : Base
{
public override string Who() => "Derived"; // ĐA HÌNH
}
public class DerivedNew : Base
{
public new string Who() => "Derived"; // CHE, không đa hình
}
Base a = new DerivedOverride();
Base b = new DerivedNew();
Console.WriteLine(a.Who()); // "Derived" ← chọn theo kiểu THẬT
Console.WriteLine(b.Who()); // "Base" ← chọn theo kiểu KHAI BÁOnew chỉ che phương thức của lớp cha chứ không thay thế nó. Kết quả phụ thuộc vào kiểu của biến, không phụ thuộc object thật — gần như luôn là điều bạn không muốn.
Khi nào new hợp lý: gần như không bao giờ trong code bạn kiểm soát. Nếu trình biên dịch cảnh báo "hides inherited member", đó thường là dấu hiệu một lớp cha vừa thêm phương thức trùng tên — và đó là thứ cần xem lại, không phải thứ cần dán new vào cho hết cảnh báo.
4.3.3 — Nguyên tắc thay thế Liskov
Mọi chỗ dùng được lớp cha thì cũng phải dùng được lớp con, mà không cần biết mình đang cầm lớp nào.
Ví dụ kinh điển cho thấy trực giác "là một" đánh lừa thế nào:
public class Rectangle
{
public virtual int Width { get; set; }
public virtual int Height { get; set; }
public int Area => Width * Height;
}
public class Square : Rectangle // Hình vuông LÀ MỘT hình chữ nhật... phải không?
{
public override int Width
{
set { base.Width = value; base.Height = value; } // giữ tính vuông
}
public override int Height
{
set { base.Width = value; base.Height = value; }
}
}using System.Diagnostics; // cần cho Debug.Assert
void Test(Rectangle r)
{
r.Width = 5;
r.Height = 4;
Debug.Assert(r.Area == 20); // đúng với Rectangle, SAI với Square (=16)
}Debug.Assert dừng chương trình (khi chạy bản Debug) nếu điều kiện sai. Bài tập 1 chạy thử ví dụ này và in kết quả ra màn hình.
Về mặt hình học, hình vuông đúng là hình chữ nhật. Về mặt hành vi, Square không thay thế được Rectangle vì nó phá vỡ một kỳ vọng: đặt chiều rộng không được làm đổi chiều cao.
Bài học: quan hệ "là một" trong đời thực không bảo đảm quan hệ "thay thế được" trong code. Tiêu chí duy nhất đáng tin là câu hỏi về hành vi.
Ba dấu hiệu bạn đang vi phạm Liskov:
- Lớp con ném ngoại lệ cho một phương thức mà lớp cha hỗ trợ.
- Lớp con không làm gì trong một phương thức override.
- Code gọi phải kiểm tra kiểu (
if (x is Square)) để xử lý đúng.
4.3.4 — Bẫy: gọi virtual trong constructor
public abstract class Report
{
protected Report()
{
Initialize(); // NGUY HIỂM
}
protected virtual void Initialize() { }
}
public class SalesReport : Report
{
private readonly List<string> _columns;
public SalesReport()
{
_columns = new List<string>(); // gán trong THÂN constructor
}
protected override void Initialize()
{
_columns.Add("Doanh thu"); // NullReferenceException!
}
}new SalesReport() ném NullReferenceException. Thứ tự thực thi khi tạo một object lớp con trong C#:
- Field initializer của lớp con (field khai báo kèm giá trị, như
= new();). - Constructor của lớp cha. Tại đây
Initialize()được gọi, và vì nó làvirtualnên bản override củaSalesReportchạy. - Thân constructor của lớp con. Dòng
_columns = new List<string>();chạy ở bước này, quá muộn.
Khi Initialize() chạy ở bước 2, _columns vẫn là null. Nếu viết private readonly List<string> _columns = new(); (field initializer) thì _columns đã có ở bước 1 và code không lỗi. Chính vì cùng một mẫu thiết kế mà lúc lỗi lúc không, tuỳ cách khai báo field, nên bẫy này rất khó phát hiện. Bài tập 2 cho bạn chạy cả hai biến thể.
Trình biên dịch không báo lỗi, và lỗi chỉ lộ ra lúc chạy. Bộ phân tích mã có quy tắc CA2214 cho đúng trường hợp này nhưng nó tắt mặc định (bài tập 2 chỉ cách bật).
Cách tránh: đừng gọi phương thức virtual trong constructor. Nếu cần khởi tạo theo từng loại, dùng factory method hoặc một bước khởi tạo mà người gọi chủ động gọi sau khi tạo xong.
4.3.5 — Composition thường đúng hơn
SmtpClient là lớp gửi email có sẵn của .NET; ISmtpClient là một interface (hợp đồng, bài 4.4) do bạn tự định nghĩa cho việc gửi email.
// KẾ THỪA — ràng buộc chặt, khó đổi
public class EmailNotifier : SmtpClient
{
public void NotifyOrderCreated(Order o) { /* ... */ }
}
// COMPOSITION — ghép lại, đổi được
public class EmailNotifier
{
private readonly ISmtpClient _smtp;
public EmailNotifier(ISmtpClient smtp) => _smtp = smtp;
public void NotifyOrderCreated(Order o) { /* dùng _smtp */ }
}Bảng so sánh:
| Kế thừa | Composition | |
|---|---|---|
| Quan hệ | là một | có một |
| Thời điểm quyết định | Lúc biên dịch | Lúc chạy |
| Số lượng | Chỉ một lớp cha | Bao nhiêu thành phần cũng được |
| Kiểm thử | Phải dựng cả cây kế thừa | Thay bằng bản giả dễ dàng |
| Ràng buộc | Chặt nhất có thể | Lỏng |
Lời khuyên đã thành quy ước: ưu tiên composition hơn kế thừa. Dùng kế thừa khi thật sự có quan hệ "là một" về mặt hành vi, và khi lớp cha được thiết kế sẵn để mở rộng.
Trong .NET hiện đại, phần lớn nhu cầu "chia sẻ code" được giải bằng interface cộng dependency injection, chứ không bằng một cây kế thừa sâu. Cây kế thừa quá ba tầng gần như luôn là dấu hiệu nên xem lại.
4.3.6 — sealed: đóng lại khi không định cho mở rộng
public sealed class OrderNumberGenerator { /* ... */ }
public class Base
{
public virtual void Run() { }
}
public class Middle : Base
{
public sealed override void Run() { } // con của Middle không override tiếp được
}Hai lý do dùng sealed:
- Ý định rõ ràng. Class này không được thiết kế để kế thừa, và bạn nói ra điều đó thay vì để người khác đoán.
- Hiệu năng. JIT biết chắc không có lớp con nào nên có thể gọi trực tiếp thay vì tra bảng ảo. Lợi ích nhỏ nhưng có thật ở vòng lặp nóng.
Thiết kế cho kế thừa là việc tốn công: phải nghĩ xem lớp con override được gì, thứ tự gọi ra sao, bất biến nào phải giữ. Nếu bạn chưa làm việc đó, sealed là lựa chọn trung thực hơn là để ngỏ.
4.3.7 — Rà lại code của bạn
Danh sách rà soát kế thừa
- Không có từ khoá new nào dùng để che phương thức của lớp cha.
- Mọi lớp con đều thay thế được lớp cha mà không cần kiểm tra kiểu ở chỗ gọi.
- Không có lớp con nào ném NotSupportedException cho phương thức lớp cha hỗ trợ.
- Không gọi phương thức virtual trong constructor.
- Cây kế thừa không sâu quá ba tầng.
- Class không thiết kế để kế thừa đều được khai sealed.
- Đã cân nhắc composition trước khi chọn kế thừa.
Bài tập áp dụng
Bài 1 — Tự dựng vi phạm Liskov
Cài đặt Rectangle và Square như mục 4.3.3, viết một hàm kiểm tra dùng cả hai kiểu. Ghi lại kết quả. Sau đó thiết kế lại để không vi phạm.
Tiêu chí hoàn thành: bạn chỉ ra được dòng nào trong hàm kiểm tra bộc lộ vi phạm, và vì sao trình biên dịch không bắt được.
Gợi ý và lời giải — Bài 1
Gợi ý. Nguyên tắc Liskov nói: ở bất kỳ chỗ nào dùng lớp cha, thay bằng lớp con thì chương trình vẫn phải đúng. Hãy viết một hàm chỉ biết Rectangle và đưa Square vào.
Lời giải — cài đặt:
public class Rectangle
{
public virtual int Width { get; set; }
public virtual int Height { get; set; }
public int Area => Width * Height;
}
public class Square : Rectangle
{
public override int Width
{
get => base.Width;
set { base.Width = value; base.Height = value; } // giữ bất biến "vuông"
}
public override int Height
{
get => base.Height;
set { base.Width = value; base.Height = value; }
}
}Hàm kiểm tra chỉ biết Rectangle (đặt đoạn này ở đầu Program.cs, hai class ở trên đặt phía sau):
void Check(Rectangle h)
{
h.Width = 5;
h.Height = 4;
Console.WriteLine($"{h.GetType().Name,-12} -> diện tích = {h.Area} (kỳ vọng 20)");
}
Check(new Rectangle());
Check(new Square());Kết quả chạy thật trên .NET 10:
Rectangle -> diện tích = 20 (kỳ vọng 20)
Square -> diện tích = 16 (kỳ vọng 20)Dòng bộc lộ vi phạm là h.Height = 4. Với Rectangle, nó chỉ đặt chiều cao. Với Square, nó âm thầm đặt lại cả chiều rộng về 4, phá vỡ giả định mà hàm Check đã ngầm dựa vào: đặt chiều cao thì chiều rộng không đổi.
Vì sao trình biên dịch không bắt được. Liskov là ràng buộc về hành vi, không phải về chữ ký. Square cài đặt đúng mọi thành viên của Rectangle với đúng kiểu, nên về mặt kiểu nó hoàn toàn hợp lệ. Thứ nó vi phạm là một hợp đồng ngầm không được viết ra ở đâu cả — và trình biên dịch không có cách nào biết về những hợp đồng ngầm.
Đây là lý do "hình vuông là hình chữ nhật" sai trong lập trình. Trong toán học, mệnh đề đó đúng vì hình vuông là một giá trị bất biến. Trong lập trình, Rectangle có hành vi thay đổi được — và chính hành vi ấy mới là thứ phải kế thừa được, không phải quan hệ tập hợp.
Ba cách thiết kế lại:
// Cách 1 — bất biến: không có setter thì không có mâu thuẫn
public record Rectangle(int Width, int Height) { public int Area => Width * Height; }
public record Square(int Side) { public int Area => Side * Side; }
// Cách 2 — chung interface (bài 4.4), không kế thừa lẫn nhau
public interface IShape { int Area { get; } }
public class Rectangle : IShape { /* ... */ }
public class Square : IShape { /* ... */ }
// Cách 3 — bỏ hẳn quan hệ, hai kiểu riêng biệtCách 1 là cách sạch nhất và cũng là lý do record xuất hiện ở bài 4.7: bất biến loại bỏ cả một lớp vấn đề về kế thừa.
Phép thử Liskov thực dụng cho code của bạn. Với mỗi lớp con, hỏi ba câu:
- Nó có thắt chặt điều kiện đầu vào không? Ví dụ lớp cha nhận mọi số, lớp con chỉ nhận số dương — vi phạm.
- Nó có nới lỏng bảo đảm đầu ra không? Lớp cha hứa trả về danh sách không rỗng, lớp con trả về rỗng — vi phạm.
- Nó có ném ngoại lệ mới mà lớp cha không ném không? Đặc biệt là
NotImplementedException— vi phạm rõ ràng nhất.
Câu thứ ba bắt được trường hợp phổ biến nhất trong code thật: lớp con kế thừa rồi ném NotSupportedException cho những phương thức nó không làm được. Đó là dấu hiệu quan hệ kế thừa sai ngay từ đầu.
Bài 2 — Chứng minh bẫy gọi virtual trong constructor
Viết một lớp cha Base gọi phương thức virtual Init() trong constructor, và hai lớp con override Init() để thêm phần tử vào một List: DerivedA khởi tạo list bằng field initializer, DerivedB gán list trong thân constructor. Tạo cả hai và chạy. Ghi lại ngoại lệ đầy đủ kèm dấu vết ngăn xếp (stack trace). Giải thích thứ tự khởi tạo đã gây ra nó.
Tiêu chí hoàn thành: bạn giải thích được vì sao cùng một mẫu code mà có trường hợp không lỗi — phụ thuộc vào cách khai báo field.
Gợi ý và lời giải — Bài 2
Gợi ý. Thứ tự khởi tạo trong C# có một chi tiết ít người nhớ: field initializer của lớp con chạy TRƯỚC constructor của lớp cha, còn thân constructor của lớp con chạy SAU. Hai cách khai báo field cho hai kết quả khác nhau.
Lời giải — hai biến thể:
// Base.cs
public class Base
{
public Base() { Console.WriteLine(" [Base ctor] gọi Init()"); Init(); }
protected virtual void Init() { }
}
// Biến thể A — field initializer
public class DerivedA : Base
{
private readonly List<string> _items = new();
protected override void Init()
{
_items.Add("x");
Console.WriteLine($" [DerivedA.Init] OK, _items có {_items.Count} phần tử");
}
}
// Biến thể B — gán trong thân constructor
public class DerivedB : Base
{
private readonly List<string> _items;
public DerivedB()
{
_items = new List<string>();
Console.WriteLine(" [DerivedB ctor] đã gán _items");
}
protected override void Init() => _items.Add("x");
}// Program.cs
Console.WriteLine("DerivedA (field initializer):");
new DerivedA();
Console.WriteLine();
Console.WriteLine("DerivedB (gán trong thân ctor):");
try
{
new DerivedB();
}
catch (NullReferenceException ex)
{
Console.WriteLine($" NÉM {ex.GetType().Name}: {ex.Message}");
Console.WriteLine(ex.StackTrace);
}Kết quả chạy thật trên .NET 10, với hai khối trên ghép thành toàn bộ Program.cs (khối Program.cs ở đầu, khối khai báo ở cuối; đường dẫn file đã rút gọn):
DerivedA (field initializer):
[Base ctor] gọi Init()
[DerivedA.Init] OK, _items có 1 phần tử
DerivedB (gán trong thân ctor):
[Base ctor] gọi Init()
NÉM NullReferenceException: Object reference not set to an instance of an object.
at DerivedB.Init() in Program.cs:line 43
at Base..ctor() in Program.cs:line 19
at DerivedB..ctor() in Program.cs:line 38
at Program.<Main>$(String[] args) in Program.cs:line 8Dòng [DerivedB ctor] đã gán _items không bao giờ được in: thân constructor của DerivedB chưa kịp chạy thì ngoại lệ đã bay ra.
Đọc dấu vết ngăn xếp từ dưới lên cho thấy chính xác thứ tự: DerivedB..ctor() gọi Base..ctor(), và Base..ctor() gọi DerivedB.Init() — trước khi thân DerivedB..ctor() kịp chạy dòng gán _items.
Thứ tự khởi tạo đầy đủ trong C#:
1. Field initializer của LỚP CON <- DerivedA an toàn nhờ bước này
2. Constructor của LỚP CHA chạy
-> tại đây lời gọi virtual đã đi tới lớp con
3. Thân constructor của LỚP CON <- DerivedB gán _items ở đây, quá muộnVì sao DerivedA không lỗi. Field initializer nằm ở bước 1, chạy trước cả constructor lớp cha, nên _items đã sẵn sàng khi Init() được gọi. Đây chính là chi tiết khiến bẫy này khó phát hiện: cùng một mẫu thiết kế, đổi cách khai báo field là hành vi đổi hoàn toàn. Code chạy tốt suốt nhiều tháng, rồi ai đó chuyển một field từ initializer sang gán trong constructor vì cần tham số, và lỗi xuất hiện.
Quy tắc. Không bao giờ gọi phương thức virtual trong constructor. Bộ phân tích mã của .NET có cảnh báo riêng cho việc này, mã CA2214, nhưng nó không bật sẵn (kể cả với <AnalysisMode>Recommended</AnalysisMode>). Bật riêng nó bằng file .editorconfig đặt ở thư mục project:
root = true
[*.cs]
dotnet_diagnostic.CA2214.severity = warningBuild lại, trình biên dịch chỉ đúng dòng gọi Init() trong constructor của Base:
warning CA2214: Do not call overridable methods in constructorsCách sửa: bỏ lời gọi virtual khỏi constructor của lớp cha. Sửa ở lớp con (DerivedB) không giải quyết được, vì chính Base mới là chỗ gọi. Hai hướng:
// Cách 1 — constructor của Base không gọi Init nữa.
// Người tạo object chủ động gọi bước khởi tạo sau khi đã dựng xong.
public class Base
{
public void Setup() => Init(); // gọi khi object đã dựng xong
protected virtual void Init() { }
}
var obj = new DerivedB();
obj.Setup(); // lúc này _items đã được gán
// Cách 2 — gói hai bước vào một factory method để người gọi không quên
public static T Create<T>() where T : Base, new()
{
var x = new T();
x.Setup();
return x;
}Factory chỉ có ích khi Base không còn tự gọi Init() trong constructor. Nếu constructor của Base vẫn gọi, factory gọi thêm lần nữa cũng không cứu được lần gọi đầu tiên.
sealed không sửa được bẫy này. Đánh dấu sealed class DerivedB chỉ ngăn ai đó kế thừa DerivedB; Base vẫn gọi bản override của DerivedB trước khi thân constructor của DerivedB chạy, nên lỗi y nguyên. sealed chỉ giúp khi đặt ở lớp cha: nếu Base không được thiết kế để kế thừa, hãy làm nó sealed và bỏ luôn virtual, lúc đó không còn bản override nào để gọi nhầm.
Bài 3 — Đổi kế thừa thành composition
Class CrmApiClient trong phần "trước" của lời giải kế thừa HttpClient (lớp gửi request HTTP của .NET) để gọi API của một hệ thống CRM. Viết lại bằng composition, rồi viết một test xUnit cho một class dùng nó (CustomerService) và so sánh độ khó với phiên bản kế thừa.
Tiêu chí hoàn thành: bạn viết được một bài kiểm thử cho phiên bản composition mà không cần mạng hay hạ tầng thật, và dotnet test xanh.
Gợi ý và lời giải — Bài 3
Gợi ý. Hỏi: CrmApiClient là một HttpClient, hay chỉ dùng HttpClient? Trong dự án thật, các lớp kế thừa HttpClient, DbContext, BackgroundService đáng được hỏi đúng câu này.
Code trong bài dùng async/await và Task<T>: Task<Customer?> nghĩa là "một kết quả kiểu Customer? sẽ có sau khi lời gọi mạng xong", còn await là chờ kết quả đó mà không chặn luồng. Bài 6.2 dạy kỹ; ở đây chỉ cần đọc hiểu. Cần thêm using System.Net; và using System.Net.Http.Json;, và Customer ở đây là public record Customer(string Name, string Email);.
Lời giải — trước, kế thừa:
public class CrmApiClient : HttpClient
{
public CrmApiClient()
{
BaseAddress = new Uri("https://api.company.com/");
DefaultRequestHeaders.Add("X-App", "CRM");
}
public async Task<Customer?> GetCustomerAsync(int id, CancellationToken ct)
{
var res = await GetAsync($"customers/{id}", ct);
if (res.StatusCode == HttpStatusCode.NotFound) return null;
res.EnsureSuccessStatusCode();
return await res.Content.ReadFromJsonAsync<Customer>(ct);
}
}Ba vấn đề:
- Lộ ra toàn bộ bề mặt của
HttpClient— hơn 20 thành viên công khai mà người dùng lớp này không cần và không nên chạm tới. - Không kiểm thử được nếu không gọi mạng thật, hoặc không dựng một
HttpMessageHandlergiả. - Không thay được cài đặt: muốn có bản giả cho môi trường phát triển thì phải kế thừa tiếp.
Sau — composition kèm interface:
public interface ICrmApiClient
{
Task<Customer?> GetCustomerAsync(int id, CancellationToken ct);
}
public class CrmApiClient : ICrmApiClient
{
private readonly HttpClient _http;
public CrmApiClient(HttpClient http) => _http = http;
public async Task<Customer?> GetCustomerAsync(int id, CancellationToken ct)
{
var res = await _http.GetAsync($"customers/{id}", ct);
if (res.StatusCode == HttpStatusCode.NotFound) return null;
res.EnsureSuccessStatusCode();
return await res.Content.ReadFromJsonAsync<Customer>(ct);
}
}Xem trước — Module 7. Trong ứng dụng ASP.NET Core, HttpClient cho CrmApiClient được tạo và quản lý bởi IHttpClientFactory, đúng như bài 2.2 đã nói là cần thiết. Bạn sẽ học cách đăng ký này ở Module 7:
builder.Services.AddHttpClient<ICrmApiClient, CrmApiClient>(c =>
{
c.BaseAddress = new Uri("https://api.company.com/");
c.DefaultRequestHeaders.Add("X-App", "CRM");
});So sánh việc viết kiểm thử. Với bản kế thừa, muốn test mà không gọi mạng thật thì phải dựng một HttpMessageHandler giả (khoảng vài chục dòng) và tìm cách nhét nó vào, vì constructor của CrmApiClient không nhận gì từ bên ngoài. Với bản composition, class nào dùng ICrmApiClient đều test được bằng một bản giả viết tay vài dòng:
public class CustomerServiceTests
{
[Fact]
public async Task GetDisplayName_ReturnsCustomerName()
{
var fakeApi = new FakeCrmApiClient(new Customer("An", "[email protected]"));
var service = new CustomerService(fakeApi);
var result = await service.GetDisplayNameAsync(42, CancellationToken.None);
Assert.Equal("An", result);
}
[Fact]
public async Task GetDisplayName_CustomerNotFound_ReturnsPlaceholder()
{
var service = new CustomerService(new FakeCrmApiClient(null));
var result = await service.GetDisplayNameAsync(42, CancellationToken.None);
Assert.Equal("(không tìm thấy)", result);
}
}
// Bản giả viết tay: không gọi mạng, trả về đúng thứ test cần
public class FakeCrmApiClient(Customer? customer) : ICrmApiClient
{
public Task<Customer?> GetCustomerAsync(int id, CancellationToken ct) => Task.FromResult(customer);
}
// Class đang được test: chỉ biết ICrmApiClient
public class CustomerService(ICrmApiClient api)
{
public async Task<string> GetDisplayNameAsync(int id, CancellationToken ct)
{
var customer = await api.GetCustomerAsync(id, ct);
return customer?.Name ?? "(không tìm thấy)";
}
}class CustomerService(ICrmApiClient api) là cú pháp primary constructor (C# 12): tham số khai báo ngay sau tên class và dùng được trong mọi phương thức (bài 4.10 giải thích kỹ). Chạy dotnet test, cả hai test xanh mà không cần mạng.
Trong dự án lớn, người ta hay dùng thư viện tạo bản giả tự động (NSubstitute, Moq) thay cho viết tay. Với người mới, bản giả viết tay như trên dễ hiểu hơn và đủ dùng.
Bảng so sánh:
| Kế thừa | Composition | |
|---|---|---|
| Bề mặt công khai | Toàn bộ HttpClient | Đúng một phương thức |
| Kiểm thử lớp gọi nó | Cần handler giả | Bản giả vài dòng |
| Đổi cài đặt | Phải kế thừa tiếp | Đăng ký lớp khác |
| Quản lý nhóm kết nối | Tự lo | IHttpClientFactory lo |
Nguyên tắc chọn. Kế thừa diễn đạt quan hệ "là một"; composition diễn đạt quan hệ "có một" hoặc "dùng một". CrmApiClient không là một HttpClient — nó dùng HttpClient để làm việc của nó. Nhầm hai quan hệ này là nguồn gốc của phần lớn cây kế thừa khó bảo trì.
Trường hợp kế thừa vẫn đúng. Khi framework yêu cầu, ví dụ class MyController : ControllerBase hay class MyJob : BackgroundService. Lúc đó kế thừa là giao diện bắt buộc với framework — nhưng ngay cả vậy, hãy giữ lớp đó mỏng và đẩy logic nghiệp vụ sang một service được tiêm vào, để phần logic ấy vẫn kiểm thử được độc lập.
Tự kiểm tra
override và new khác nhau ra sao?
override thật sự thay thế phương thức của lớp cha, nên phương thức được chọn theo kiểu THẬT của object lúc chạy — đó mới là đa hình. new chỉ che phương thức cha, nên phương thức được chọn theo kiểu KHAI BÁO của biến. Kết quả là cùng một object cho ra hai kết quả khác nhau tuỳ cách bạn khai báo biến, gần như luôn là điều không mong muốn.
Nguyên tắc thay thế Liskov nói gì?
Mọi chỗ dùng được lớp cha thì cũng phải dùng được lớp con mà không cần biết mình đang cầm lớp nào. Ví dụ hình vuông kế thừa hình chữ nhật cho thấy quan hệ là một trong đời thực không bảo đảm quan hệ thay thế được trong code: Square phá vỡ kỳ vọng rằng đặt chiều rộng không làm đổi chiều cao.
Ba dấu hiệu vi phạm Liskov là gì?
Lớp con ném ngoại lệ cho một phương thức mà lớp cha hỗ trợ; lớp con override rồi không làm gì cả; và code gọi phải kiểm tra kiểu để xử lý đúng. Dấu hiệu thứ ba rõ nhất — nếu phải viết if x is Square thì đa hình đã không hoạt động.
Vì sao không được gọi phương thức virtual trong constructor?
Vì thứ tự khởi tạo là: field initializer của lớp con, rồi constructor của lớp cha, rồi mới tới thân constructor của lớp con. Phương thức virtual gọi từ constructor cha sẽ chạy bản override của lớp con ở bước 2, khi những field mà lớp con gán trong thân constructor (bước 3) vẫn còn null. Trình biên dịch không báo lỗi, quy tắc CA2214 lại tắt mặc định, nên lỗi chỉ lộ ra lúc chạy. Cách sửa là bỏ lời gọi virtual khỏi constructor của lớp cha; đánh dấu sealed cho lớp con không giúp gì.
Khi nào chọn composition thay vì kế thừa?
Gần như luôn, trừ khi thật sự có quan hệ là một về mặt hành vi và lớp cha được thiết kế sẵn để mở rộng. Composition cho phép quyết định lúc chạy, ghép bao nhiêu thành phần cũng được, và thay bằng bản giả khi viết test rất dễ. Cây kế thừa sâu quá ba tầng gần như luôn là dấu hiệu nên xem lại.
sealed có lợi ích gì?
Hai thứ. Thứ nhất là nói rõ ý định rằng class này không được thiết kế để kế thừa, thay vì để người khác đoán. Thứ hai là hiệu năng: JIT biết chắc không có lớp con nên gọi trực tiếp được thay vì tra bảng ảo — lợi ích nhỏ nhưng có thật ở vòng lặp nóng.
Kết luận
Ba điều đáng nhớ nhất:
- "Là một" trong đời thực không đồng nghĩa "thay thế được" trong code. Chỉ hành vi mới quyết định.
- Gọi
virtualtrong constructor là bẫy im lặng. Trình biên dịch không nói gì, lỗi chờ tới lúc chạy. - Ưu tiên composition. Kế thừa là ràng buộc chặt nhất bạn có thể tạo ra giữa hai class.
Tham khảo
- Inheritance in C#
- Polymorphism — kèm phần
newvàoverride - Object-oriented programming in C#
- Framework design guidelines — phần thiết kế cho kế thừa
Điều hướng
- Bài trước: 4.2 — Encapsulation
- Bài tiếp theo: 4.4 — Interface
- Về module: Trang mục lục