Value type và reference type trong C#

(Cập nhật: Trong C# in depth (2nd edition) có một phần thảo luận chi tiết về vấn đề này, nếu bạn quan tâm thì nên tham khảo, đừng đọc phần dưới.)

Hôm nọ vào diễn đàn nào đấy thấy giải thích sự khác nhau giữa value type và reference type trong C#. Đại loại là biến value type nằm trên stack, biến reference type nằm trên heap. Có lẽ suy nghĩ này ảnh hưởng từ C/C++ gì đó chăng, nhưng nói chung là không chính xác. Về các đặc điểm của value type và reference type, MSDN đã trình bày đầy đủ, nên ở đây không lặp lại nữa. Quay lại chuyện stack và heap. Giả sử như ý trên đúng, tức là object tạo từ class nằm trên heap. Nếu object này có field int là value type, thì field này nằm ở đâu? Theo như ý trên thì nó nằm ở stack. Object nằm trên heap mà field nằm ở stack thì cũng hơi lạ nhỉ.

Thực ra, tôi nghĩ cái ý tưởng về stack và heap không đúng mà cũng chẳng sai. Vấn đề là, nó thuộc về hiện thực của CLR, và chúng ta không thể và cũng không cần biết chuyện nó lưu trữ các giá trị như thế nào. Việc chúng ta cần quan tâm chỉ là các tính chất và hành vi của chúng mà thôi.

Không phải chỉ C#, ngay cả với C++ thì việc sử dụng khái niệm stack và heap cũng là không cần thiết và có vẻ không chuẩn. Cụ thể thế nào thì các bạn có thể tìm hiểu thêm các khái niệm automatic storage, static storage, and dynamic storage (chẳng hạn, trong quyển C++ primer plus, đây cũng là một quyển rất tốt về C++).

Khác biệt giữa Parallel.ForEach() và ParallelEnumerable.ForAll() trong .NET framework

Parallel.ForEach() là một static method cho phép thực hiện song song các thao tác trên một Enumerable object. ParallelEnumerable.ForAll() là một extension method cho phép thực hiện song song các thao tác trên một Enumerable object do một câu truy vấn tạo ra (hơi khó giải thích). Tuy nhiên, không chỉ khác biệt về cách sử dụng, chúng cũng có khác biệt về performance nói chung.

Giả sử bạn gặp tình huống xử lí trên một biến data kiểu Enumerable như sau:

var q = from d in data … select d;
foreach (var item in q) { doSomething(item); }

Nếu bạn muốn câu truy vấn q chạy ở chế độ song song (chẳng hạn, khi bạn có nhiều core và muốn tận dụng chúng) bạn dùng ParallelEnumerable.AsParallel():

var q = from d in data.AsParallel() … select d;
foreach (var item in q) { doSomething(item); }

Và nếu hàm doSomething() không quan tâm đến thứ tự xử lí của item (tức là gọi doSomething với item nào trước thì kết quả cũng ra như nhau) thì bạn cũng có thể chạy foreach ở chế độ song song:

var q = from d in data.AsParallel() … select d;
Parallel.ForEach(q, item => { doSomething(item) });

Nhưng bạn cũng có thể dùng ForAll với câu truy vấn q để đạt hiệu quả tương tự:

var q = (from d in data.AsParallel() … select d);
q.ForAll(item => { doSomething(item) });

Nhìn hai đoạn code cuối ta có cảm giác performance của chúng giống nhau, nhưng thực ra không phải như vậy. Tạm gọi đoạn code dùng Parallel.ForEach() là đoạn code (1) và đoạn code dùng ParallelEnumerable.ForAll() là đoạn code (2).

Các công việc mà đoạn code (1) thực hiện (không hoàn toàn chính xác trên thực tế, nhưng đủ để cho thấy sự khác biệt) là:

  • Chạy câu truy vấn:
    • Phân hoạch data ra thành nhiều phần (để chạy song song).
    • Chạy câu truy vấn trên từng data, tạm gọi kết quả là q1, q2, q3, v.v.
    • Trộn q1, q2, q3, v.v lại thành Enumerable object q.
  • Chạy Parallel.ForEach():
    • Phân hoạch q ra thành nhiều phần (để chạy song song).
    • Gọi doSomething();

Sơ đồ minh họa cho đoạn code (1).

image

Đoạn code (1) lãng phí ở chỗ đầu tiên ta trộn các q1, q2, q3, v.v lại thành q, sau đó lại phân hoạch q ra. Sẽ tốt hơn nếu sau khi phân hoạch data, câu truy vấn được chạy, và các item lấy ra sẽ được cho vào doSomething() luôn. Nói cách khác doSomething() trở thành một phần trong hoạt động của câu truy vấn. Đó chính là cách mà đoạn code (2) thi hành. Dưới đây là sơ đồ minh họa cho đoạn code (2):

image

Đó là lí do lại sao đoạn code (2) cho performance tốt hơn, và được khuyên dùng trong những tình huống tương tự.

Nếu muốn tìm hiểu thêm về Parallel trong C#, bạn có thể đọc Paterns of Parallel Programming (sách miễn phí từ Microsoft).

Event trong C#

Khai báo

Dưới đây là các bước tạo một event. Xem thêm chi tiết ở đây.

public class CustomEventArgs: EventArgs {…} 
public delegate void CustomEventHandler(object sender, CustomEventArgs a); 
public event CustomEventHandler RaiseCustomEvent;

Nếu bạn dùng Generics, bạn không cần phải tự tạo delegate:

public class CustomEventArgs: EventArgs {…} 
public event EventHandler<CustomEventArgs> RaiseCustomEvent;

Nếu bạn không cần một class EventArgs riêng, bạn không phải tạo CustomEventArgs:

public event EventHandler<EventArgs> RaiseCustomEvent;

Raise event

Cần chú ý là bạn nên tách phần code để raise event ra khỏi phần code kiểm tra điều kiện và tạo EventArgs, vì bạn sẽ thấy tiếp theo đây, raise event đòi hỏi một số thao tác linh tinh có thể làm code của bạn rối rắm hơn:

void OnRaiseCustomEvent(CustomEventArgs e) { 
  if (RaiseCustomEvent != null) { 
    RaiseCustomEvent(this, e); 
  } 
}

Tại sao phải kiểm tra null? Vì nếu RaiseCustomEvent là null, tức là chưa có ai subscribe, lệnh raise event sẽ throw Exception.

Vẫn còn một vấn đề nữa. Trong một chương trình multithread, rất có khả năng một object nào đó sẽ hủy subscribe trước khi raise event, nhưng sau khi phép kiểm tra null xảy ra. Nếu việc hủy subscribe này làm cho event thành null, bạn hiểu điều gì sẽ xảy ra. Do đó, bạn phải tạo một bản copy cho event cần raise:

var handler = RaiseCustomEvent; 
if (handler != null) …

Bây giờ thì dù RaiseCustomEvent có thay đổi thế nào, handler event cũng không bị ảnh hưởng. Tại sao một phép copy lại làm được điều đó? C# copy reference chứ không copy value mà? Nếu bạn quan tâm, xin mời đọc tiếp. Nếu không, hãy an tâm như thế là đủ.

Rườm rà? Bạn không thích lặp lại đoạn mã này với tất cả các event, chẳng hạn khi bạn có quá nhiều event (Custom1, Custom2, v.v)? Có thể dùng extension method để làm cho code gọn đẹp hơn nữa, nhưng hẹn bài khác :D.

Vì sao vừa có event, vừa có delegate

Để ý rằng, với delegate, chúng ta cũng có các toán tử +=, –=, và cũng invoke được các delegate đã add, tương tự như event. Vậy tại sao chúng ta lại phải tạo event mà không dùng luôn delegate? Bởi vì nếu dùng delegate, sẽ có hai tình huống không mong đợi sau:

  • Subscriber override các subscriber khác bằng toán tử =. Chẳng hạn, một cách vô tình, lệnh subscribe được viết là … = … Kết quả là tất cả các delegate đã add vào CustomEventHandler trước đó sẽ bị xóa.
  • Subscriber có thể raise event bằng cách gọi delegate. Trong khi việc này lẽ ra chỉ nên được thực hiện bởi publisher.

Event thực chất được xây dựng dựa trên delegate, nhưng không cho phép các tình huống trên xảy ra. Khi khai báo một event, trình dịch sẽ ngầm tạo cho chúng ta một biến delegate private trong chính class chứa event. Lưu ý là event là một khái niệm ở mức CIL, vì vậy nó không đơn thuần chỉ là một wrapper cho delegate. Bạn có thể tham khảo đoạn mã dưới đây (từ quyển Essential C# 4.0), mô tả một cách sơ lược những gì trình dịch sẽ làm.

public delegate void CustomEventHandler(object sender, CustomEventArgs a);

private CustomEventHandler customEventHandler;

public void add_CustomEventHandler(CustomEventHandler handler) { 
  System.Delegate.Combine(customEventHandler, handler); 
}

public void remove_CustomEventHandler(CustomEventHandler handler) { 
  System.Delegate.Remove(customEventHandler, handler); 
}

public event CustomEventHandler customEventHandler { 
  add { add_customEventHandler(value) } 
  remove { remove_customEventHandler(value) } 
}

Và delegate là một immutable type

Để dễ hiểu, hãy xét kiểu string, cũng immutable, và cũng hỗ trợ toán tử += như delegate:

string s1 = "old"; 
string s2 = s1; 
s1 += “new”;

s2 vẫn sẽ là “old”, vì khi gán s1 += “new”, một chuỗi mới sẽ được tạo ra với giá trị là “oldnew”, và s1 sẽ trỏ sang chuỗi mới đó. Chuỗi s2, tất nhiên vẫn trỏ tới chuỗi cũ. Bây giờ xem lại phần copy trong raise event, bạn sẽ thấy vấn đề hoàn toàn sáng tỏ.

(Xóa phần immutable object, vì HM muốn viết nó thành một bài riêng)

Linh tinh về dynamic trong C#

Dưới đây là vài điểm đáng chú ý liên quan đến kiểu dynamic trong C# được trình bày trong buổi thảo luận với hai developer của Microsoft, theo trình tự thời gian. Ở đây không nói đến hiện thực bên trong, vì HM không nắm rõ.

  • Lí do khiến dynamic được đưa vào C# là để làm việc với COM (chú ý là cả COM cũng phải được “tân trang” thì mới tận dụng được dynamic), và các ngôn ngữ dynamic như Ruby, Python được đơn giản hơn. Các ứng dụng phát sinh thêm như ở đây không phải là lí do chính thức.
  • dynamic là một built-in type như int và float, chứ không phải một syntax mới. Các biến dynamic cũng là object. Với các biến kiểu dynamic, trình biên dịch C# sẽ không thực hiện kiểm tra kiểu lúc biên dịch mà chỉ đóng gói các thông tin cần thiết để phân giải lúc runtime lời gọi các method của dynamic object. Lí do đằng sau thiết kế này là Microsoft không muốn thay đổi tính chất static typing của C#, và cũng không muốn lập trình viên dùng dynamic một cách không có chủ ý.
  • dynamic là một contextual keyword (bạn thử khai báo int dynamic = 1; xem).
  • dynamic dùng các dịch vụ của Dynamic Language Runtime (DLR). DLR là thư viện (chứ không phải runtime như CLR, mặc dù tên gọi có từ “runtime”) cung cấp các dịch vụ cần thiết khi xây dựng các ngôn ngữ dynamic trên nền .NET. Có thể coi dynamic là syntactic sugar để sử dụng DLR trong C#. Khi các ngôn ngữ dynamic trên nền .NET sử dụng DLR, việc cộng tác với nhau sẽ đơn giản hơn.
  • Dynamic object expose các method, property vào lúc runtime, nói cách khác, nó có thể tự dispatch thông điệp gửi đến nó. Khi gán một object cho biến kiểu dynamic, trình dịch wrap các object này vào các dynamic object.
  • Lập trình viên C# có thể lựa chọn dùng hoặc không dùng dynamic khi làm việc với COM và các ngôn ngữ dynamic. Điều này phụ thuộc vào sở thích.

Tham khảo blog của hai lập trình viên này ở đâyở đây nếu muốn biết sâu hơn về dynamic (có thể dùng thay thuốc ngủ :D).

Một ứng dụng của dynamic trong C#

Với dynamic type được giới thiệu ở phiên bản 4.0, C# giờ đây đã hỗ trợ dynamic typing. Các bạn có thể xem một số ví dụ tại MSDN.

Cách dùng thì đơn giản rồi, điều quan trọng là khi nào thì dùng dynamic? Xem bài này nếu bạn quan tâm đến lí do chính thức mà Microsoft đưa dynamic vào C#. Ở đây chỉ nói về một ví dụ rất đơn giản mà các sách hay dùng.

Static typing giúp phát hiện các lỗi về method signature (ví dụ, trình dịch sẽ bắt lỗi khi bạn truyền thiếu tham số), type compability (ví dụ, bạn không thể gán object kiểu Book cho biến kiểu Car được). Hơn nữa, Intellisense (chức năng nhắc mã trong IDE) chỉ hoạt động được với các biến static (vì biến dynamic không có thông tin về kiểu). Như vậy có vẻ static typing là một lựa chọn quá an toàn, quá tiện lợi, và không có lí do gì để dùng dynamic trong C#.

Tuy nhiên, theo như một số sách và trang web, thì có những tình huống mà static typing cũng không giúp phát hiện lỗi cho chúng ta, chẳng hạn như khi parse XML:

string bookDef = "<Book><Title>The wedding singer</Title></Book>" 
XElement book = XElement.Parse(bookDef); 
Console.WriteLine(book.Descendants("Title").FirstOrDefault().Value);

bookDef có thể là string được tạo sẵn, cũng có thể do người dùng nhập vào, hay đọc từ tập tin, v.v. Dù thế nào đi nữa, trình dịch không thể (và không quan tâm) giá trị của nó khi dịch lệnh XElement book… Điều đó nghĩa là nếu bạn lỡ viết nhầm “Title” thành “Tittle” trong lệnh Console.WriteLine, trình dịch cũng không thể phát hiện được, và bạn sẽ nhận Exception vào lúc runtime.

string bookDef = "<Book><Title>The wedding singer</Title></Book>" 
dynamic book = DynamicXml.Parse(bookDef); 
Console.WriteLine(book.Title);

XML được parse lúc runtime thành một object, với property là các element. Object này được gán cho book, và chúng ta có thể lấy thông tin từ XML một cách rất… OOP. Điều này có thể thực hiện được với static typing sử dụng Reflection, nhưng dynamic giúp cho công việc trở nên đơn giản hơn rất nhiều. Và chúng ta cũng không mất gì cả, vì đằng nào thì static typing cũng không giúp bắt những lỗi như “Tittle” khi biên dịch.

Để xây dựng DynamicXml, tham khảo hướng dẫn của MSDN.

Chú ý là có những thứ trong XML khó có thể diễn tả trong C#, như namespace, quy tắc đặt tên, v.v, cho nên ví dụ này chỉ ở mức ứng dụng là làm ví dụ :D. HM nghĩ nếu xây dựng được một DynamicXml hỗ trợ đầy đủ XML, thì việc sử dụng nó cũng chả đơn giản hơn XElement là mấy (chưa nói đến chúng ta đã có LINQ2XML).

Dĩ nhiên là dynamic có những lí do hợp lí để tồn tại, nhưng đó là chuyện khác.

Covariance và contravariance trong C# Generics

Xét interface IList<T> với hai method là void Add(T item) và T Get(int index), và List<T> là class implement IList<T>. Ngoài ra ta có các kiểu cha con Mammal, Elephant.

Bây giờ ta có lệnh gán IList<Mammal> a = new List<Elephant>(). Vì a có kiểu khai báo là IList<Mammal>, nó có thể gọi method void Add(Mammal item). Tuy nhiên, List<Elephant>() lại chỉ hiện thực method void Add(Elephant item), nên ta không thể gọi a.Add(tiger). Do đó có hai phương án. Phương án thứ nhất là trình biên dịch chấp nhận lệnh gán trên, các tình huống gây lỗi sẽ được bắt lúc runtime. Phương án thứ hai là không cho biên dịch lệnh gán này. C# chọn phương án thứ hai. Lí do có lẽ là để phù hợp với quan điểm về static typing.

Còn method Mammal Get(int index)? Giả sử ta gọi Mammal b = a.Get(0). Bởi vì List<Elephant> hiện thực method Elephant Get(int index), và Elephant là con của Mammal, nên lệnh gọi này hoàn toàn hợp lệ cả lúc biên dịch lẫn runtime.

Như vậy, nếu chúng ta chỉ sử dụng type T (trong IList<T>) cho các giá trị output, thì sẽ không bao giờ xảy ra chuyện rắc rối như với a.Add(Elephant item). Chính vì lí do này, C# 4.0 giới thiệu thêm kí hiệu out cho phép ta chú thích với trình biên dịch rằng, type T chỉ dùng cho output. Nói cách khác, nếu IList<T> chỉ có method Get, ta có thể khai báo interface này là IList<out T>, và khi đó IList<Mammal> a = new List<Elephant>() là một phép gán hợp lệ.

Trong tình huống ngược lại, IList<Elephant> a = new List<Mammal>(), thì method Get mới lại là kẻ gây rắc rối. Vì method này trong IList<Elephant> trả về Elephant, còn hiện thực của List<Mammal> lại trả về Mammal, và tất nhiên không phải tất cả các Mammal đều là Elephant. Tương tự như trường hợp ban đầu, nếu ở đây, IList<T> không có Get, tức là T không được dùng cho giá trị output mà chỉ cho input, thì phép gán sẽ hoàn toàn hợp lệ. Để chú thích cho trình duyệt điều này, ta dùng từ khóa in, tương tự như out.

Còn hai thuật ngữ covariance và contravariance liên quan gì đến in và out? Dựa vào bài viết này, có thể liên hệ như sau: IList<out T> cho phép bảo toàn chiều “lớn hơn” giữa Mammal và Elephant, còn IList<in T> đảo chiều “lớn hơn” giữa Elephant và Mammal.

Nãy giờ bạn có thắc mắc tại sao lại là IList<…> a = new List<…> chứ không phải List<…> a = new List<…> không? Đó là vì CLR chỉ hỗ trợ tính năng này cho interface và delegate (theo "C# in depth”). Vậy tại sao CLR lại chỉ hỗ trợ như vậy. Câu trả lời dễ hiểu nhất mà HM từng thấy là ở đây, do một thành viên trong nhóm Visual C# đưa ra:

Sure, that’s a reasonable example but you haven’t shown anything that couldn’t also be done with interfaces. Just make interface ILookup<out T> and have Lookup<T> implement it. What compelling additional benefit over interface variance does your scenario for class variance add?

We don’t have to provide a justification for not implementing a feature. Not implementing a feature is free. Rather, we have to provide a justification for implementing a feature — features can cost millions of dollars to Microsoft, and impose an even larger burden upon our customers who must then spend time and money learning about the feature.

Đoạn comment thứ hai cho chúng ta thêm một câu hỏi: vậy tính năng vừa bàn ở trên trong C# 4.0 dùng để làm gì? Khi nào thì dùng? Cái này thì phải suy nghĩ đã :D.

Checked exception trong Java và C#

Có một thứ mình từng thấy rất khó chịu khi dùng C#, là nó không hỗ trợ checked exception. Trong Java, checked exception nôm na là các exception không phải do lỗi lập trình, mà là những tình huống đặc biệt, như không tìm được file, v.v. Bạn bắt buộc phải catch các exception này, hoặc throw lại nó. Với C#, tất cả các exception đều là unchecked. Điều đó có nghĩa là bạn phải dựa vào tài liệu hỗ trợ hoặc xem trực tiếp code để biết các exception mà một method có thể throw. Và việc không chắc chắn phải catch các exception có vẻ như sẽ gây trở ngại trong việc xây dựng một chương trình tốt.

Nhưng theo Anders Hejlsberg (kiến trúc sư trưởng của C#), thì cái cách Java hiện thực checked exception có vẻ không tốt. Tức là, checked exception vẫn là một ý tưởng hữu ích:

Frankly, they look really great up front, and there’s nothing wrong with the idea. I completely agree that checked exceptions are a wonderful feature. It’s just that particular implementations can be problematic. By implementing checked exceptions the way it’s done in Java, for example, I think you just take one set of problems and trade them for another set of problems. In the end it’s not clear to me that you actually make life any easier. You just make it different.

Nhưng giải pháp Java đưa ra thì có vấn đề:

The concern I have about checked exceptions is the handcuffs they put on programmers. You see programmers picking up new APIs that have all these throws clauses, and then you see how convoluted their code gets, and you realize the checked exceptions aren’t helping them any. It is sort of these dictatorial API designers telling you how to do your exception handling. They should not be doing that.

Alan Griffiths cũng đồng ý rằng giải pháp của Java gây ra một số vấn đề, trong đó có việc phá vỡ tính bao đóng (mà cũng không biết ông này là ông nào, có điều thấy bài viết cũng được). Nhưng ngắn gọn lại thì cũng như quan điểm của Hejlsberg—lập trình viên sớm muộn sẽ catch và throw một loạt exception Exception:

The throws clause, at least the way it’s implemented in Java, doesn’t necessarily force you to handle the exceptions, but if you don’t handle them, it forces you to acknowledge precisely which exceptions might pass through. It requires you to either catch declared exceptions or put them in your own throws clause. To work around this requirement, people do ridiculous things. For example, they decorate every method with, "throws Exception." That just completely defeats the feature, and you just made the programmer write more gobbledy gunk. That doesn’t help anybody.

Cái này mình cũng đồng ý, vì mình cũng hay làm thế cho nhanh. Vấn đề là Exception thì bao gồm cả RuntimeException, là những exception mà chúng ta không nên tự xử lí. Mình thấy tốt hơn hết là để cho lập trình viên catch những exception họ muốn/cần catch, và phần còn lại sẽ được throw tự động.

Có một lời giải thích khá thú vị cho giải pháp C# đưa ra—không hỗ trợ exception, hay chính xác hơn là không đưa ra giải pháp thực sự nào. Đó là vì họ chưa nghĩ ra.

C# is basically silent on the checked exceptions issue. Once a better solution is known—and trust me we continue to think about it—we can go back and actually put something in place. I’m a strong believer that if you don’t have anything right to say, or anything that moves the art forward, then you’d better just be completely silent and neutral, as opposed to trying to lay out a framework.

And so, when you take all of these issues, to me it just seems more thinking is needed before we put some kind of checked exceptions mechanism in place for C#. But that said, there’s certainly tremendous value in knowing what exceptions can get thrown, and having some sort of tool that checks. I don’t think we can construct hard and fast rules down to, it is either a compiler error or not. But I think we can certainly do a lot with analysis tools that detect suspicious code, including uncaught exceptions, and points out those potential holes to you.

Cũng là một lời khuyên hay. Nếu không thể làm cho tình huống tốt hơn thì hãy để nó nguyên như thế, ít nhất nó cũng sẽ không tệ đi.

Vẫn còn bảy bài phỏng vấn với ông trùm ngôn ngữ lập trình này, phải ráng đọc cho hết.