Chuyển tới nội dung chính

5.6 — 5. Extension Methods

Tóm tắt

Extension method cho phép "thêm" phương thức vào một kiểu bạn không sở hữu — string, DateTime, IEnumerable. Thực chất nó chỉ là phương thức static với cú pháp gọi đẹp hơn, và hai hệ quả của điều đó hay làm người ta bất ngờ: phương thức của chính class luôn được ưu tiên hơn extension cùng tên, và gọi được trên một tham chiếu null mà không ném NullReferenceException — vì không có lời gọi trên instance nào cả. Toàn bộ LINQ được xây trên cơ chế này. Bài này cũng nói về mặt trái: extension đặt sai chỗ làm code khó lần nguồn gốc.

Mục tiêu bài học​

Sau bài này bạn có thể:

  • Viết extension method đúng cú pháp và đặt nó ở namespace hợp lý.
  • Giải thích độ ưu tiên khi tên trùng với phương thức của class.
  • Dự đoán đúng hành vi khi gọi extension trên null.
  • Nhận ra ba dấu hiệu lạm dụng extension method.
  • Dùng extension để viết IServiceCollection và IApplicationBuilder gọn hơn.

Nội dung bài học​

5.6.1 — Cú pháp và bản chất​

public static class StringExtensions          // phải static
{
public static bool IsValidEmail(this string? value) // this ở tham số ĐẦU
=> !string.IsNullOrWhiteSpace(value)
&& value.Contains('@')
&& value.IndexOf('@') < value.LastIndexOf('.');
}

// Gọi như phương thức của string
if (input.IsValidEmail()) { }

// Trình biên dịch dịch thành
if (StringExtensions.IsValidEmail(input)) { }

Ba điều kiện bắt buộc: class phải static, phương thức phải static, tham số đầu tiên phải có từ khoá this.

Điểm cuối cùng — nó chỉ là đường cú pháp — giải thích mọi hành vi còn lại của extension method.

5.6.2 — Độ ưu tiên: class luôn thắng​

public class Order
{
public string Describe() => "từ class";
}

public static class OrderExtensions
{
public static string Describe(this Order o) => "từ extension";
}

new Order().Describe(); // "từ class"

Phương thức của chính class luôn được ưu tiên. Extension chỉ được xét khi trình biên dịch không tìm thấy phương thức phù hợp trên kiểu đó.

Hệ quả thực tế đáng lưu ý: nếu tác giả thư viện thêm một phương thức trùng tên vào class ở phiên bản sau, extension của bạn âm thầm ngừng được gọi. Code vẫn biên dịch, hành vi đổi. Đây là lý do nên tránh đặt tên extension trùng với tên có khả năng xuất hiện trong chính kiểu đó.

Khi có nhiều extension cùng tên từ nhiều namespace, trình biên dịch báo lỗi mơ hồ và bạn phải gọi tường minh dạng static.

5.6.3 — Gọi được trên null​

public static bool IsEmpty(this string? s) => string.IsNullOrEmpty(s);

string? name = null;
bool result = name.IsEmpty(); // TRUE — không ném ngoại lệ

Vì đây thực chất là StringExtensions.IsEmpty(null) — một lời gọi static với đối số null — nên không có lời gọi trên instance nào để mà NullReferenceException.

Điều này vừa hữu ích vừa nguy hiểm:

// Hữu ích — viết được hàm kiểm tra null rất gọn
public static bool HasValue(this string? s) => !string.IsNullOrWhiteSpace(s);

// Nguy hiểm — trông như gọi trên instance, người đọc tưởng đã chắc chắn khác null
public static string Normalize(this string s) => s.Trim().ToLowerInvariant();
name.Normalize(); // NỔ, nhưng nổ BÊN TRONG extension chứ không phải ở đây

Quy tắc: nếu extension có thể nhận null, hãy khai tham số là string? và xử lý tường minh. Nếu không, khai string và thêm ArgumentNullException.ThrowIfNull ở đầu để lỗi chỉ đúng chỗ.

5.6.4 — LINQ là extension method​

// Toàn bộ LINQ nằm trong System.Linq.Enumerable
public static IEnumerable<T> Where<T>(this IEnumerable<T> source, Func<T, bool> predicate)

Đây là lý do bạn phải using System.Linq mới thấy Where, Select. Không có using thì extension không nằm trong phạm vi và trình biên dịch báo "không tìm thấy phương thức".

Và đây cũng là lý do LINQ hoạt động với mọi thứ cài đặt IEnumerable<T> — kể cả kiểu do bạn viết, mà không cần Microsoft biết trước về nó. Đó chính là bài toán extension method sinh ra để giải: mở rộng một interface đã phát hành mà không sửa nó.

5.6.5 — Nơi extension toả sáng trong ASP.NET Core​

// Không có extension — Program.cs phình ra hàng trăm dòng
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<ICustomerRepository, SqlCustomerRepository>();
// ... 40 dòng nữa ...

// Có extension — mỗi tầng tự khai báo phần của mình
builder.Services
.AddInfrastructure(builder.Configuration)
.AddApplication()
.AddApiVersioning();
public static class InfrastructureServiceCollectionExtensions
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services, IConfiguration config)
{
services.AddDbContext<AppDbContext>(o =>
o.UseNpgsql(config.GetConnectionString("Default")));
services.AddScoped<IOrderRepository, SqlOrderRepository>();
return services; // trả về để nối chuỗi
}
}

Hai chi tiết đáng chú ý: trả về chính đối tượng để nối chuỗi được, và đặt lớp extension trong cùng project với thứ nó đăng ký — nhờ vậy Program.cs không cần biết SqlOrderRepository tồn tại, đúng tinh thần internal ở bài 4.7.

5.6.6 — Ba dấu hiệu lạm dụng​

1. Extension cho kiểu bạn sở hữu.

// Bạn viết ra Order — vậy thêm phương thức vào thẳng class
public static decimal GetDiscount(this Order order) => /* ... */;

Extension không truy cập được thành viên private, nên logic nghiệp vụ nằm ở đó sẽ chỉ dùng được dữ liệu công khai — đẩy bạn về phía anemic model.

2. Extension chứa logic nghiệp vụ nặng.

// Lấy dữ liệu từ database bên trong một extension của string?
public static Customer? ToCustomer(this string email) { /* truy vấn DB */ }

Extension nên là hàm thuần, biến đổi dữ liệu. Có phụ thuộc thì nó phải là một service nhận qua constructor.

3. Extension trong namespace quá rộng.

namespace System;      // đổ mọi extension vào đây

Mọi file trong solution tự động thấy chúng, kể cả nơi không cần. Nên đặt trong namespace của chính module — MyApp.Orders.Extensions — để người dùng chọn using một cách có ý thức.

Phép thử đơn giản: "Người đọc code này có đoán được phương thức đó đến từ đâu không?" Nếu không, extension đang gây hại nhiều hơn giúp.

5.6.7 — Rà lại code của bạn​

Danh sách rà soát extension method

  • •Chỉ viết extension cho kiểu bạn KHÔNG sở hữu.
  • •Extension là hàm thuần, không gọi database hay HTTP bên trong.
  • •Extension nhận null đều khai tham số nullable và xử lý tường minh.
  • •Không đặt extension trong namespace System hay namespace gốc của dự án.
  • •Tên extension không trùng với tên có khả năng xuất hiện trong chính kiểu đó.
  • •Extension đăng ký DI trả về chính đối tượng để nối chuỗi.
  • •Lớp extension đăng ký DI nằm cùng project với thứ nó đăng ký.

Bài tập áp dụng​

Bài 1 — Chứng minh độ ưu tiên: class luôn thắng​

Tạo một class có phương thức Describe() và một extension cùng tên. Gọi và ghi kết quả. Sau đó xoá phương thức trong class và gọi lại.

Tiêu chí hoàn thành: bạn nêu được hậu quả của quy tắc này khi nâng cấp một thư viện.

Gợi ý và lời giải — Bài 1

Gợi ý. Trình biên dịch tìm phương thức theo thứ tự: trước hết trong chính kiểu đó và các lớp cha, chỉ khi không tìm thấy mới tìm tới extension method.

Lời giải.

public class Customer
{
public string Describe() => "từ class";
}

public static class CustomerExtensions
{
public static string Describe(this Customer c) => "từ extension";
}

var c = new Customer();
Console.WriteLine(c.Describe()); // "từ class"

Xoá phương thức trong class, giữ nguyên extension:

Console.WriteLine(c.Describe());     // "từ extension"

Không có cảnh báo nào ở cả hai trường hợp. Trình biên dịch lặng lẽ chọn, và bạn không biết nó đã chọn cái nào nếu không đọc kỹ.

Hậu quả khi nâng cấp thư viện — đây là phần quan trọng. Giả sử bạn viết một extension để bù cho thứ thư viện chưa có:

// Thư viện version 1.0 chưa có phương thức này
public static class SdkExtensions
{
public static async Task<T?> GetAsync<T>(this ISdkClient c, string id)
=> /* cài đặt riêng của bạn, có xử lý lỗi và retry */;
}

Nâng thư viện lên 2.0, và bản mới thêm một phương thức GetAsync vào chính ISdkClient. Kể từ lúc đó:

  • Mọi lời gọi client.GetAsync(...) âm thầm chuyển sang dùng bản của thư viện.
  • Extension của bạn — với phần xử lý lỗi và thử lại — không bao giờ được gọi nữa.
  • Code vẫn biên dịch sạch. Không cảnh báo, không lỗi.
  • Hành vi đổi: có thể khác về xử lý lỗi, khác về giá trị trả về khi không tìm thấy, khác về việc có thử lại hay không.

Đây là một trong những cách đổi hành vi khó phát hiện nhất, vì không có gì trong code của bạn thay đổi.

Ba cách phòng:

  1. Đặt tên extension khác với tên có thể xuất hiện trong thư viện. Thêm tiền tố của dự án, ví dụ CrmGetAsync. Xấu hơn nhưng an toàn.
  2. Viết một bài kiểm thử khẳng định hành vi, không chỉ khẳng định kết quả. Nếu extension của bạn có thử lại, hãy kiểm thử rằng nó thử lại thật.
  3. Đọc ghi chú phát hành khi nâng phiên bản chính. Phương thức mới thêm vào interface là thay đổi đáng chú ý, dù về mặt kỹ thuật nó không phá vỡ biên dịch.

Một biến thể tinh vi hơn: default interface method. Từ C# 8, interface có thể có phương thức với cài đặt sẵn. Quy tắc ưu tiên lúc đó phức tạp hơn — cài đặt trong lớp thắng cài đặt mặc định của interface, và extension method vẫn xếp sau cùng. Khi cả ba cùng tồn tại, việc suy ra cái nào được gọi trở nên khó, và đó là lý do nên tránh để ba thứ cùng tên tồn tại.

Bài 2 — Gọi extension trên null​

Viết một extension nhận string? và một extension nhận string. Gọi cả hai với biến null. Ghi lại cái nào chạy được và cái nào ném ở đâu.

Tiêu chí hoàn thành: bạn giải thích được vì sao gọi phương thức trên null lại không ném NullReferenceException.

Gợi ý và lời giải — Bài 2

Gợi ý. Extension method chỉ là cú pháp đường cho một phương thức tĩnh. Hãy viết lại lời gọi dưới dạng phương thức tĩnh thông thường và câu trả lời sẽ rõ.

Lời giải.

public static class StringExtensions
{
public static bool LaRong(this string? s) => string.IsNullOrWhiteSpace(s);
public static int DoDai(this string s) => s.Length;
}

string? x = null;

Console.WriteLine(x.LaRong()); // True — CHẠY BÌNH THƯỜNG
Console.WriteLine(x.DoDai()); // NullReferenceException

Vì sao cái thứ nhất chạy được. Trình biên dịch dịch lời gọi extension thành lời gọi phương thức tĩnh:

StringExtensions.LaRong(x);      // x = null là một tham số hợp lệ
StringExtensions.DoDai(x); // cũng gọi được, nhưng bên trong s.Length ném

Vì đây là phương thức tĩnh, runtime không cần tìm bảng phương thức ảo trên đối tượng, nên không có phép giải tham chiếu nào xảy ra tại điểm gọi. null chỉ đơn giản là giá trị của tham số đầu tiên.

Ngoại lệ ở DoDai không phát sinh tại x.DoDai() mà phát sinh bên trong thân hàm, ở dòng s.Length. Chi tiết này quan trọng khi đọc dấu vết ngăn xếp.

Ứng dụng hữu ích — viết API dễ chịu hơn:

public static bool HasValue(this string? s) => !string.IsNullOrWhiteSpace(s);

public static IReadOnlyList<T> OrEmpty<T>(this IEnumerable<T>? source)
=> source?.ToList() ?? [];

// Dùng được mà không cần kiểm tra null trước
if (customer.Note.HasValue()) { }
foreach (var x in maybeNullList.OrEmpty()) { }

Cái bẫy — người đọc không biết nó an toàn. Nhìn customer.Note.HasValue(), không ai đoán được đây là extension chấp nhận null. Họ sẽ tưởng Note chắc chắn khác null và viết customer.Note.Trim() ngay dòng dưới — dòng đó thì ném thật.

Vì vậy hãy đánh dấu rõ ràng bằng kiểu tham số và bằng thuộc tính:

public static bool HasValue([NotNullWhen(true)] this string? s)
=> !string.IsNullOrWhiteSpace(s);

[NotNullWhen(true)] nói với trình biên dịch: khi hàm trả true thì s chắc chắn khác null. Nhờ vậy:

if (customer.Note.HasValue())
{
var x = customer.Note.Trim(); // không còn cảnh báo — trình biên dịch đã biết
}

Đây là cách dạy trình biên dịch hiểu hàm của bạn, chủ đề chính của bài 5.7.

Một lưu ý khi bật Nullable Reference Types. Extension nhận string (không có dấu chấm hỏi) mà bị gọi trên một biến string? sẽ nhận cảnh báo CS8604. Đó là điều tốt — cảnh báo đúng chỗ. Đừng dập nó bằng toán tử !; hãy đổi tham số thành string? nếu hàm thật sự xử lý được null, hoặc kiểm tra null trước khi gọi.

Bài 3 — Dọn Program.cs bằng extension method​

Lấy một dự án có Program.cs dài hơn 80 dòng đăng ký dịch vụ. Gom theo tầng thành các extension AddXxx và đo số dòng giảm được.

Tiêu chí hoàn thành: Program.cs dưới 40 dòng, và bạn nêu được lợi ích kiến trúc ngoài chuyện ngắn hơn.

Gợi ý và lời giải — Bài 3

Gợi ý. Nhóm các đăng ký theo tầng hoặc theo tính năng, không theo loại kỹ thuật. Mỗi nhóm thành một extension đặt trong đúng dự án mà nó thuộc về.

Lời giải — trước, 94 dòng:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<CrmDbContext>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("Crm")));
builder.Services.AddScoped<ICustomerRepository, EfCustomerRepository>();
builder.Services.AddScoped<IOrderRepository, EfOrderRepository>();
builder.Services.AddScoped<ILeadRepository, EfLeadRepository>();
builder.Services.AddScoped<ICustomerService, CustomerService>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();
builder.Services.AddStackExchangeRedisCache(o => { /* ... */ });
builder.Services.AddAuthentication(/* 12 dòng */);
builder.Services.AddAuthorization(/* 8 dòng */);
builder.Services.AddSwaggerGen(/* 15 dòng */);
// ... còn 40 dòng nữa

Sau, 18 dòng:

var builder = WebApplication.CreateBuilder(args);

builder.Services
.AddCrmDomain()
.AddCrmInfrastructure(builder.Configuration)
.AddCrmAuthentication(builder.Configuration)
.AddCrmApi();

var app = builder.Build();

app.UseCrmPipeline();
app.MapControllers();
app.Run();

Với mỗi extension đặt trong đúng dự án mà nó cấu hình:

// Crm.Infrastructure/DependencyInjection.cs
namespace Microsoft.Extensions.DependencyInjection; // để không phải thêm using

public static class InfrastructureServiceCollectionExtensions
{
public static IServiceCollection AddCrmInfrastructure(
this IServiceCollection services, IConfiguration config)
{
services.AddDbContext<CrmDbContext>(o =>
o.UseNpgsql(config.GetConnectionString("Crm")));

services.AddScoped<ICustomerRepository, EfCustomerRepository>();
services.AddScoped<IOrderRepository, EfOrderRepository>();
services.AddScoped<ILeadRepository, EfLeadRepository>();
services.AddSingleton<IEmailSender, SmtpEmailSender>();

return services; // trả về để nối chuỗi
}
}

94 xuống 18 dòng. Nhưng lợi ích kiến trúc mới là phần chính:

  1. Mỗi dự án tự khai báo cách đăng ký của nó. Thêm một repository vào Crm.Infrastructure chỉ cần sửa file trong chính dự án đó — Program.cs và các dự án khác không đụng tới. Đây cũng là cách để dự án hạ tầng không lộ các lớp cài đặt ra ngoài, đúng như bài 4.7 đã nêu: chúng có thể là internal, chỉ extension method là public.

  2. Program.cs thành bản đồ hệ thống. Đọc 18 dòng là biết ứng dụng gồm những phần nào. Đọc 94 dòng đăng ký thì không.

  3. Giảm xung đột Git. Với Program.cs dài, mọi người thêm dịch vụ đều sửa cùng một file, nên xung đột liên tục — đúng vấn đề ở bài 3.8. Tách ra thì mỗi người sửa file của tầng mình.

Hai quy ước nên theo:

  • Đặt namespace là Microsoft.Extensions.DependencyInjection. Nhờ vậy Program.cs không cần thêm using cho từng dự án — cùng cách mà chính Microsoft làm với các gói của họ.
  • Luôn return services. Cho phép nối chuỗi, và đó là quy ước mà mọi thư viện .NET đều theo.

Áp dụng cho cả đường ống middleware:

public static WebApplication UseCrmPipeline(this WebApplication app)
{
app.UseExceptionHandler();
app.UseHttpsRedirection();
app.UseRouting();
app.UseCors("Default");
app.UseAuthentication();
app.UseAuthorization();
return app;
}

Lưu ý ở đây thứ tự vẫn quan trọng như bài 8.3 trình bày — gom vào extension không làm thay đổi điều đó, chỉ đưa nó vào một chỗ duy nhất để dễ rà soát và ghi chú lý do.

Tự kiểm tra​

Câu hỏi thường gặp

Extension method thực chất là gì?

Là một phương thức static trong một class static, với tham số đầu tiên có từ khoá this. Trình biên dịch dịch lời gọi kiểu input.IsValidEmail thành StringExtensions.IsValidEmail(input). Nó chỉ là đường cú pháp, và điều đó giải thích mọi hành vi còn lại của nó.

Khi extension trùng tên với phương thức của class thì sao?

Phương thức của class luôn thắng; extension chỉ được xét khi không tìm thấy phương thức phù hợp trên kiểu đó. Hệ quả cần cảnh giác: nếu tác giả thư viện thêm phương thức trùng tên ở phiên bản sau, extension của bạn âm thầm ngừng được gọi — code vẫn biên dịch nhưng hành vi đổi.

Vì sao gọi extension trên biến null lại không ném ngoại lệ?

Vì đó thực chất là một lời gọi static với đối số null, không có lời gọi trên instance nào cả. Điều này hữu ích để viết hàm kiểm tra null gọn gàng, nhưng cũng nguy hiểm vì người đọc tưởng đã chắc chắn khác null. Nếu extension có thể nhận null thì nên khai tham số là kiểu nullable và xử lý tường minh.

Vì sao LINQ cần using System.Linq?

Vì toàn bộ LINQ là extension method nằm trong lớp System.Linq.Enumerable. Không có using thì chúng không nằm trong phạm vi và trình biên dịch báo không tìm thấy phương thức. Đây cũng là lý do LINQ hoạt động với mọi kiểu cài đặt IEnumerable, kể cả kiểu bạn tự viết.

Vì sao không nên viết extension cho kiểu mình sở hữu?

Vì extension không truy cập được thành viên private, nên logic đặt ở đó chỉ dùng được dữ liệu công khai, đẩy bạn về phía anemic model. Kiểu của chính bạn thì thêm phương thức vào thẳng class, nơi nó truy cập được trạng thái nội bộ và giữ được bất biến.

Nên đặt extension ở namespace nào?

Namespace của chính module đó, ví dụ MyApp.Orders.Extensions, để người dùng chọn using một cách có ý thức. Đặt vào namespace System hay namespace gốc khiến mọi file trong solution tự động thấy chúng, kể cả nơi không cần, và người đọc không đoán được phương thức đến từ đâu.

Kết luận​

Ba điều đáng nhớ nhất:

  1. Chỉ là phương thức static với cú pháp đẹp. Mọi hành vi lạ của extension đều suy ra từ câu này.
  2. Chỉ viết cho kiểu bạn không sở hữu. Kiểu của mình thì thêm phương thức vào thẳng class.
  3. Phép thử: người đọc có đoán được nó đến từ đâu không? Không đoán được thì extension đang gây hại.

Tham khảo​

Điều hướng​