Menu

Event C#: từ khóa event, EventHandler, đăng ký và kích hoạt

Cách event hoạt động trong C#: khai báo event với EventHandler và EventHandler<T>, lớp EventArgs tự định nghĩa, đăng ký và hủy đăng ký bằng += và -=, kích hoạt event an toàn với ?.Invoke, vì sao event tốt hơn một trường delegate public, và rò rỉ bộ nhớ do quên hủy đăng ký.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Event (sự kiện) cho phép một lớp thông báo rằng có chuyện vừa xảy ra mà không cần biết ai đang lắng nghe. Các đối tượng khác đăng ký bằng +=, và khi lớp kích hoạt event, phương thức của mọi subscriber đều chạy.

Đầu ra:

Order A-17 saved
  receipt emailed for A-17
Order A-18 saved
  receipt emailed for A-18
  free gift added to A-18

Shop không hề biết đến hóa đơn hay quà tặng. Nó kích hoạt OrderPlaced, và bất cứ thứ gì đã đăng ký sẽ phản ứng. Thêm một phản ứng mới (điểm thưởng, phân tích dữ liệu) nghĩa là thêm một subscriber, không phải sửa PlaceOrder.

Khai báo event

Khai báo event là một thành viên có kiểu delegate kèm từ khóa event:

public event EventHandler<OrderPlacedEventArgs> OrderPlaced;

Kiểu delegate quyết định chữ ký của handler. Code .NET gần như luôn dùng một trong hai kiểu có sẵn, và theo đúng quy ước giúp event của bạn quen thuộc với mọi lập trình viên .NET khác:

  • EventHandler: void (object sender, EventArgs e), cho event không mang dữ liệu. Kích hoạt bằng EventArgs.Empty.
  • EventHandler<TEventArgs>: void (object sender, TEventArgs e), trong đó TEventArgs là lớp của bạn chứa dữ liệu.

Các quy ước đi kèm:

  • sender là đối tượng đã kích hoạt event, thường là this.
  • Lớp dữ liệu được đặt tên {Something}EventArgs, kế thừa từ EventArgs (không bắt buộc từ .NET 4.5, nhưng vẫn là thói quen chung) và cung cấp các thuộc tính chỉ đọc.
  • Event được đặt tên theo việc đã xảy ra: OrderPlaced, Closed, PriceChanged. Tên dạng ...ing (Closing) dùng cho event được kích hoạt trước hành động, thường kèm cách để hủy hành động đó.
  • Trong các lớp dùng để kế thừa, việc kích hoạt đi qua một phương thức protected virtual void OnOrderPlaced(OrderPlacedEventArgs e), để lớp dẫn xuất có thể can thiệp.

Đăng ký và hủy đăng ký

+= thêm một handler và -= gỡ nó ra. Handler có thể là một phương thức hoặc một lambda. Muốn gỡ lambda về sau, hãy giữ nó trong một biến, vì viết lại đúng lambda đó sẽ tạo ra một delegate khác mà -= không tìm thấy:

Đầu ra:

set to 32
  display shows 32 C
  alarm: too hot
set to 35
  display shows 35 C
set to 20
  display shows 20 C

Gỡ một handler chưa từng được thêm không phải là lỗi; việc này không làm gì cả. EventHandler<int> cho thấy trên .NET hiện đại, đối số kiểu không bắt buộc phải kế thừa từ EventArgs, dù một lớp riêng vẫn chừa chỗ để thêm trường về sau mà không làm hỏng subscriber.

Handler chạy đồng bộ, theo thứ tự đăng ký, trên luồng đã kích hoạt event. Nếu một handler ném ngoại lệ, các handler còn lại không chạy và ngoại lệ lan lên code đã kích hoạt event.

Kích hoạt event an toàn

Một event chưa có subscriber nào chứa null. Gọi trực tiếp sẽ ném NullReferenceException, nên hãy kích hoạt bằng toán tử null-conditional:

OrderPlaced?.Invoke(this, args);

Trước C# 6, cách làm là sao chép trường vào một biến cục bộ, kiểm tra rồi gọi bản sao. Bản sao quan trọng trong code đa luồng: kiểm tra OrderPlaced != null rồi gọi OrderPlaced(...) sẽ đọc trường hai lần, và một luồng khác có thể gỡ handler cuối cùng ở giữa. ?. chỉ đọc một lần, nên vừa ngắn gọn vừa đúng.

Vì sao dùng event mà không dùng trường delegate public

Không có từ khóa event, một trường delegate public vẫn dùng để đăng ký được, nhưng nó trao toàn quyền cho mọi subscriber:

Đầu ra:

analytics ping
analytics ping

Chỉ một dấu = gõ nhầm thay cho += đã âm thầm gỡ bỏ hai handler trước đó, và code bên ngoài có thể kích hoạt "click" mà không cần cú click nào. Đánh dấu trường là event khiến cả hai dòng đều thành lỗi biên dịch:

error CS0070: The event 'Button.Clicked' can only appear on the left hand side of += or -= (except when used from within the type 'Button')

Bên trong Button, event vẫn hoạt động như một trường delegate bình thường, nên lớp có thể gọi nó và kiểm tra null.

Rò rỉ bộ nhớ do quên hủy đăng ký

Khi bạn đăng ký, event lưu một delegate, và delegate đó giữ tham chiếu đến đối tượng subscriber. Chừng nào publisher còn sống và handler còn gắn vào, subscriber không thể được bộ thu gom rác (garbage collector) dọn đi, và nó vẫn tiếp tục nhận event dù bạn đã dùng xong:

Đầu ra:

  closed widget shows 101.5
  open widget shows 101.5
subscribers left: 1
  closed widget shows 99.0

Gán leaky bằng null không thay đổi gì với feed: danh sách handler của nó vẫn tham chiếu đến widget đó, nên widget "đã đóng" vẫn tiếp tục cập nhật và nằm lại trong bộ nhớ. Widget trong khối using đã hủy đăng ký trong Dispose và ngừng nhận event. Đây là một trong những kiểu rò rỉ bộ nhớ phổ biến nhất trong code .NET cho desktop và máy chủ, đặc biệt với event static và các service dùng cho toàn ứng dụng, vốn sống đến khi tiến trình kết thúc.

Quy tắc: ai đăng ký event trên một đối tượng sống lâu hơn mình thì phải hủy đăng ký, thường là trong Dispose (xem câu lệnh using). Khi publisher và subscriber có cùng vòng đời, chẳng hạn một form và các nút của chính nó, thì không cần hủy đăng ký.

Accessor add và remove tự định nghĩa

Một event có thể tự định nghĩa việc += và -= làm gì, giống get và set của thuộc tính. Cách này hiếm gặp, nhưng là cách các framework lưu handler trong một từ điển dùng chung hoặc chuyển tiếp chúng sang một đối tượng khác:

private EventHandler closed;

public event EventHandler Closed
{
    add    { Console.WriteLine("subscriber added");   closed += value; }
    remove { Console.WriteLine("subscriber removed"); closed -= value; }
}

Với accessor tự định nghĩa, lớp kích hoạt event thông qua trường lưu trữ (closed?.Invoke(this, EventArgs.Empty)), vì bản thân event không còn nơi lưu trữ.

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

Event trong C# là gì?

Event là một thành viên của lớp cho phép các đối tượng khác đăng ký để được báo khi có chuyện xảy ra. Bên dưới là một multicast delegate, nhưng code bên ngoài chỉ có thể thêm handler (+=) và gỡ handler (-=). Chỉ lớp khai báo event mới có thể kích hoạt nó.

Event và delegate trong C# khác nhau thế nào?

Delegate là một kiểu tham chiếu đến phương thức. Event là một thành viên được khai báo với kiểu delegate cộng thêm từ khóa event, từ khóa này giới hạn quyền truy cập: từ bên ngoài lớp, bạn không thể gọi nó, đọc danh sách handler hay gán bằng =, mà chỉ có thể đăng ký và hủy đăng ký. Một trường delegate public cho phép tất cả những việc đó, nên bất kỳ subscriber nào cũng có thể xóa sạch các subscriber khác hoặc tự kích hoạt event.

Làm sao truyền dữ liệu qua event trong C#?

Tạo một lớp kế thừa từ EventArgs với dữ liệu là các thuộc tính chỉ đọc (public class OrderPlacedEventArgs : EventArgs { public decimal Total { get; } }) và khai báo event là EventHandler<OrderPlacedEventArgs>. Kích hoạt bằng OrderPlaced?.Invoke(this, new OrderPlacedEventArgs(total));.

Làm sao kích hoạt event an toàn trong C#?

Dùng MyEvent?.Invoke(this, args);. Một event chưa có subscriber nào có giá trị null, nên gọi trực tiếp sẽ ném NullReferenceException. Toán tử ?. chỉ đọc trường một lần, nhờ đó cũng tránh được tình huống tranh chấp khi một luồng khác gỡ handler cuối cùng giữa lúc kiểm tra null và lúc gọi.

Event trong C# có gây rò rỉ bộ nhớ không?

Có. Khi đăng ký, event lưu một delegate tham chiếu đến subscriber, nên publisher giữ subscriber sống chừng nào publisher còn sống. Một publisher sống lâu (event static, một service dùng cho toàn ứng dụng) với các subscriber sống ngắn không bao giờ hủy đăng ký sẽ giữ tất cả chúng trong bộ nhớ. Hãy hủy đăng ký bằng -= khi subscriber xong việc, thường là trong Dispose.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu