Tìm hiểu chuyên sâu về Delegate trong C#

Trong C#, một delegate là một kiểu dữ liệu đặc biệt có khả năng đại diện cho các phương thức có cùng một danh sách tham số và kiểu trả về nhất định. Khi bạn khởi tạo một đối tượng delegate, bạn có thể liên kết nó với bất kỳ phương thức nào có chữ ký (signature) tương thích. Điều này cho phép bạn gọi phương thức đó thông qua đối tượng delegate đã tạo.

Mục đích chính của delegate là cho phép truyền các phương thức như những tham số cho các phương thức khác. Một ví dụ điển hình là các trình xử lý sự kiện (event handlers), nơi các phương thức được gọi thông qua delegate. Bạn có thể xây dựng một phương thức tùy chỉnh mà một lớp, chẳng hạn như điều khiển giao diện người dùng, có thể gọi khi một sự kiện cụ thể xảy ra. Dưới đây là một khai báo delegate minh họa:

public delegate double CalculateOperation(double num1, double num2);

Bạn có thể gán bất kỳ phương thức nào, dù là phương thức tĩnh hay phương thức thể hiện (instance method), từ bất kỳ lớp hoặc cấu trúc nào có thể truy cập được và phù hợp với kiểu delegate cho một đối tượng delegate. Khả năng này mang lại sự linh hoạt để thay đổi cách gọi phương thức theo lập trình và cho phép chèn mã mới vào các lớp hiện có.

Lưu ý quan trọng: Khi nói đến delegate, chữ ký của phương thức bao gồm cả kiểu trả về. Điều này khác với ngữ cảnh nạp chồng phương thức (method overloading), nơi kiểu trả về không được tính vào chữ ký. Điều này có nghĩa là một phương thức và một delegate phải có cùng kiểu trả về để có thể tương thích.

Khả năng tham chiếu một phương thức như một tham số làm cho delegate trở thành lựa chọn lý tưởng để định nghĩa các phương thức callback. Chẳng hạn, một tham chiếu đến một phương thức so sánh hai đối tượng có thể được truyền làm tham số cho một thuật toán sắp xếp. Vì logic so sánh được tách riêng, thuật toán sắp xếp có thể được viết một cách tổng quát hơn.

Đặc điểm của Delegate

Các delegate có những đặc tính sau:

  • Chúng tương tự như con trỏ hàm (function pointer) trong C++, nhưng được an toàn về kiểu (type-safe).
  • Chúng cho phép truyền phương thức như một tham số.
  • Chúng có thể được dùng để định nghĩa các phương thức callback.
  • Nhiều delegate có thể được xâu chuỗi (chain) lại với nhau, cho phép gọi nhiều phương thức cho một sự kiện duy nhất.
  • Phương thức được gán không cần phải khớp hoàn toàn với kiểu delegate (ví dụ: covariant return types và contravariant argument types được hỗ trợ).
  • Một đối tượng delegate thường được tạo bằng cách cung cấp tên của phương thức mà nó sẽ gói gọn hoặc sử dụng phương thức ẩn danh (anonymous method) hoặc biểu thức lambda.

Sau khi được khởi tạo, một delegate sẽ chuyển cuộc gọi phương thức đến phương thức mà nó gói gọn. Các tham số mà bên gọi truyền cho delegate sẽ được chuyển đến phương thức đó, và delegate sẽ trả về giá trị trả về của phương thức (nếu có) cho bên gọi. Quá trình này được gọi là "gọi delegate". Một delegate đã được khởi tạo có thể được gọi giống như chính phương thức mà nó gói gọn. Ví dụ:

// Định nghĩa một delegate
public delegate void NotificationHandler(string infoMessage);

// Một phương thức tĩnh phù hợp với chữ ký của NotificationHandler
public static void DisplayMessage(string content)
{
    Console.WriteLine($"[Thông báo]: {content}");
}

// Khởi tạo delegate và gán phương thức
NotificationHandler myHandler = DisplayMessage;

// Gọi delegate
myHandler("Chào thế giới!");

Các kiểu delegate trong C# được kế thừa từ lớp System.Delegate trong .NET Framework. Các kiểu delegate là các lớp sealed, nghĩa là chúng không thể được kế thừa bởi các lớp khác và bạn không thể tạo một lớp tùy chỉnh kế thừa từ System.Delegate.

Vì một đối tượng delegate đã được khởi tạo là một đối tượng, nó có thể được truyền làm tham số hoặc gán cho một thuộc tính. Điều này cho phép một phương thức chấp nhận một delegate làm tham số và gọi delegate đó sau này. Đây là một mô hình phổ biến được gọi là callback không đồng bộ, được dùng để thông báo cho bên gọi khi một quá trình dài kết thúc. Khi delegate được sử dụng theo cách này, mã gọi delegate không cần biết phương thức triển khai cụ thể nào sẽ được sử dụng. Chức năng này tương tự như chức năng được cung cấp bởi một interface gói gọn.

Một trường hợp sử dụng phổ biến khác của callback là định nghĩa một phương thức so sánh tùy chỉnh và truyền delegate đó vào một phương thức sắp xếp. Điều này cho phép mã của bên gọi trở thành một phần của thuật toán sắp xếp. Phương thức ví dụ dưới đây chấp nhận một delegate kiểu NotificationHandler làm tham số:

public void ProcessAndNotify(int valA, int valB, NotificationHandler notifier)
{
    int sumResult = valA + valB;
    notifier($"Kết quả tổng là: {sumResult}");
}

Sau đó, bạn có thể truyền delegate đã tạo ở trên vào phương thức này:

NotificationHandler myHandler = DisplayMessage; // Sử dụng handler đã định nghĩa trước
ProcessAndNotify(5, 7, myHandler);

Bạn sẽ nhận được đầu ra sau trên console:

[Thông báo]: Kết quả tổng là: 12

Khi sử dụng delegate một cách trừu tượng, phương thức ProcessAndNotify không cần phải gọi trực tiếp console; nó không được thiết kế để biết về console. Vai trò của ProcessAndNotify chỉ đơn giản là chuẩn bị một chuỗi và chuyển chuỗi đó cho một phương thức khác. Chức năng này đặc biệt mạnh mẽ vì phương thức được gói gọn bởi delegate có thể có bất kỳ số lượng tham số nào.

Khi một delegate được xây dựng để gói gọn một phương thức thể hiện, nó sẽ tham chiếu cả thể hiện và phương thức. Delegate không biết về kiểu của thể hiện ngoài phương thức được gói gọn, vì vậy một delegate có thể tham chiếu đến một phương thức trên bất kỳ đối tượng nào miễn là đối tượng đó có một phương thức phù hợp với chữ ký của delegate. Khi một delegate được xây dựng để gói gọn một phương thức tĩnh, nó chỉ tham chiếu đến phương thức. Hãy xem xét khai báo sau:

public class MyService
{
    public void LogAction(string description) { Console.WriteLine($"Ghi nhận: {description}"); }
    public void TrackEvent(string eventName) { Console.WriteLine($"Theo dõi sự kiện: {eventName}"); }
}

Cùng với phương thức tĩnh DisplayMessage đã trình bày trước đó, chúng ta hiện có ba phương thức mà các thể hiện của NotificationHandler có thể gói gọn.

Delegate đa hướng (Multicast Delegates)

Khi được gọi, một delegate có thể gọi nhiều phương thức. Điều này được gọi là đa hướng (multicasting). Để thêm một phương thức khác vào danh sách các phương thức của delegate (gọi là danh sách gọi - invocation list), bạn chỉ cần sử dụng toán tử cộng (+) hoặc toán tử gán cộng (+=) giữa hai delegate. Ví dụ:

MyService service = new MyService();

NotificationHandler handler1 = service.LogAction;
NotificationHandler handler2 = service.TrackEvent;
NotificationHandler handler3 = DisplayMessage; // Phương thức tĩnh đã định nghĩa trước

// Kết hợp các delegate
NotificationHandler combinedHandlers = handler1;
combinedHandlers += handler2;
combinedHandlers += handler3; // Thêm phương thức tĩnh vào danh sách

Tại thời điểm này, danh sách gọi của combinedHandlers chứa ba phương thức: LogAction, TrackEventDisplayMessage. Các delegate ban đầu (handler1, handler2, và handler3) vẫn không thay đổi. Khi combinedHandlers được gọi, tất cả ba phương thức sẽ được gọi theo thứ tự. Nếu delegate sử dụng tham số tham chiếu (ref hoặc out), tham chiếu sẽ được truyền đến cả ba phương thức theo thứ tự ngược lại, và bất kỳ thay đổi nào do một phương thức thực hiện sẽ được các phương thức khác thấy. Khi một phương thức ném ra một ngoại lệ không được bắt bên trong phương thức, ngoại lệ đó sẽ được truyền đến bên gọi của delegate, và các phương thức tiếp theo trong danh sách gọi sẽ không được gọi. Nếu một delegate có giá trị trả về và/hoặc tham số đầu ra, nó sẽ trả về giá trị trả về và các tham số của phương thức được gọi cuối cùng.

Để xóa một phương thức khỏi danh sách gọi, hãy sử dụng toán tử trừ (-) hoặc toán tử gán trừ (-=). Ví dụ:

// Xóa phương thức LogAction
combinedHandlers -= handler1;

// Sao chép combinedHandlers đồng thời xóa handler2
NotificationHandler remainingHandler = combinedHandlers - handler2;

Vì các kiểu delegate kế thừa từ System.Delegate, bạn có thể gọi các phương thức và thuộc tính được định nghĩa bởi lớp đó trên các delegate. Ví dụ, để truy vấn số lượng phương thức trong danh sách gọi của một delegate, bạn có thể viết:

int countInvoked = handler3.GetInvocationList().Length;

Delegate có nhiều phương thức trong danh sách gọi được gọi là MulticastDelegate, một lớp con của System.Delegate. Vì cả hai lớp đều hỗ trợ GetInvocationList, mã trên sẽ hoạt động trong cả hai trường hợp.

Delegate đa hướng được sử dụng rộng rãi trong xử lý sự kiện. Đối tượng nguồn sự kiện gửi thông báo sự kiện đến các đối tượng nhận đã đăng ký để nhận sự kiện đó. Để đăng ký một sự kiện, bên nhận cần tạo một phương thức để xử lý sự kiện, sau đó tạo một delegate cho phương thức đó và truyền delegate đó đến nguồn sự kiện. Khi sự kiện xảy ra, nguồn sẽ gọi delegate. Delegate sau đó sẽ gọi phương thức xử lý sự kiện trên bên nhận, cung cấp dữ liệu sự kiện. Kiểu delegate cho một sự kiện nhất định được xác định bởi nguồn sự kiện. Để biết thêm thông tin, hãy xem Events (C# Programming Guide).

So sánh hai delegate có kiểu khác nhau trực tiếp tại thời điểm biên dịch sẽ gây ra lỗi biên dịch. Tuy nhiên, nếu thể hiện delegate có kiểu System.Delegate tĩnh, việc so sánh được phép, nhưng sẽ trả về false tại thời điểm chạy nếu kiểu thực thi không khớp. Ví dụ:

delegate void ActionDelegateA();
delegate void ActionDelegateB();

static void CompareDelegates(ActionDelegateA delA, ActionDelegateB delB, System.Delegate genericDel)
{
    // Lỗi biên dịch: Không thể so sánh các kiểu delegate khác nhau trực tiếp.
    // Console.WriteLine(delA == delB); 

    // Hợp lệ lúc biên dịch. Sẽ là 'false' nếu kiểu thực thi của genericDel 
    // không giống với kiểu của delA.
    Console.WriteLine(delA == genericDel);
}

Thẻ: C# delegate callback Multicast Delegate Event Handling

Đăng vào ngày 25 tháng 7 lúc 17:13