Khi bạn xây dựng một class, nhiệm vụ đầu tiên và rất quan trọng là khởi tạo giá trị cho các thành viên của class (như biến, property). Nếu class có rất nhiều biến và property, việc khởi tạo giá trị nếu không được thực hiện đúng cách có thể gây ra nhiều hậu quả về sau, lúc bạn bắt đầu sử dụng class đó.
Do vậy, khởi tạo các thành viên của class là một nhiệm vụ quan trọng.
Như bạn vẫn được học trong các khóa OOP, nhiệm vụ của hàm tạo (constructor) là khởi tạo giá trị đầu cho các thành viên.
Tuy vậy, trong C# có một số điểm cần lưu ý khi sử dụng hàm tạo. Đặc biệt nếu bạn xuất phát từ C++, việc khởi tạo thành viên class trong C# có nhiều điểm khác biệt.
Trong bài viết này chúng ta sẽ điểm qua một số kinh nghiệm giúp bạn viết code khởi tạo thành viên class ngắn gọn, đầy đủ, không lặp code.
Sử dụng bộ khởi tạo (initializer) trong C# cho biến thành viên
Vấn đề với hàm tạo trong C#
Class thường có nhiều hàm tạo và thực hiện khởi tạo giá trị đầu cho nhiều thành viên.
Theo thời gian, biến thành viên và hàm tạo có thể không còn đồng bộ nhau nữa. Nghĩa là bạn có thể khai báo thêm một số biến thành viên nhưng lại không có lệnh khởi tạo tương ứng trong constructor. Điều này dẫn đến có những biến thành viên không hề được khởi tạo mà phải nhận giá trị mặc định của kiểu do C# tự gán.
Điều này gây ra một số vấn đề. Hãy nhìn đoạn code sau:
public class SampleClass { private string _firstName; private string _lastName; public SampleClass() { _firstName = "Unknown"; _lastName = "Unknown"; } }
Ban đầu bạn chỉ khai báo 2 biến _firstName và _lastName, đồng thời khởi tạo giá trị phù hợp ở hàm tạo.
Sau này bạn bổ sung thêm một biến nữa:
public class SampleClass { private string _firstName; private string _lastName; private List<string> _labels; public SampleClass() { _firstName = "Unknown"; _lastName = "Unknown"; } }
Tuy nhiên bạn lại quên mất không khởi tạo giá trị cho nó trong hàm tạo.
Nếu ở bất kỳ chỗ nào bạn sử dụng đến biến _labels, bạn sẽ gặp NullException vì biến _labels được C# gán cho giá trị mặc định null.
Hiện tượng này tạo ra sự không đồng bộ giữa khai báo và khởi tạo giá trị cho biến thành viên.
Sử dụng bộ khởi tạo (initializer) trong C#
Cách tốt nhất để tránh tình trạng này là khởi tạo biến ngay tại nơi nó được khai báo sử dụng bộ khởi tạo (initializer):
public class SampleClass { private string _firstName = "Unknown"; private string _lastName = "Unknown"; private List<string> _labels = new List<string>(); }
Khi này dù bạn có bao nhiêu constructor, các biến trên đều được khởi tạo. Bạn thậm chí còn không cần xây dựng constructor riêng nữa.
Vậy điều gì xảy ra khi sử dụng initializer?
Trên thực tế, C# compiler không hề vi phạm những gì bạn đã biết về lập trình hướng đối tượng. Hàm tạo vẫn là nơi dùng để khởi tạo giá trị đầu cho các biến.
Tuy nhiên khi sử dụng initializer, C# compiler giúp bạn tự sinh những đoạn code khởi tạo tạo giá trị tương ứng và đặt vào đầu của mỗi hàm tạo bạn viết. Do đó, initializer luôn chạy trước các lệnh trong constructor.
Cũng vì lý do trên, nếu bạn vừa khởi tạo bằng initializer vừa khởi tạo trong constructor, giá trị bạn gán trong constructor sẽ được sử dụng.
Nếu bạn không tự viết hàm tạo nào, C# compiler thậm chí còn sinh ra một hàm tạo giúp bạn. Loại hàm tạo này được gọi là hàm tạo mặc định (default constructor).
Những trường hợp không nên sử dụng initializer
Có ba tình huống bạn không nên sử dụng initializer:
- Bạn khởi tạo biến với giá trị 0 hoặc null;
- Biến cần khởi tạo với giá trị khác nhau trong mỗi constructor;
- Bạn cần xử lý ngoại lệ hoặc các logic phức tạp khi khởi tạo biến.
Chúng ta sẽ phân tích kỹ hơn các vấn đề trên.
Ở tình huống thứ nhất, việc khởi tạo giá trị 0 hoặc null là dư thừa. Khi khai báo một biến thành viên, C# compiler tự động gán cho nó giá trị zero (đối với kiểu value) hoặc null (đối với kiểu reference). Nếu bạn tiếp tục gọi lệnh khởi tạo với cùng giá trị mặc định, bạn đang bắt C# compiler thực hiện thao tác dư thừa.
Đọc thêm về các kiểu cơ sở của C# nếu bạn không phân biệt được value type và reference type.
Đối với trường hợp thứ hai, bạn cần nhớ lại cách hoạt động của initializer mà chúng ta đã nói ở trên: C# compiler sẽ copy lệnh khởi tạo và đặt vào đầu mỗi constructor bạn xây dựng. Nếu trong constructor bạn khởi tạo một giá trị khác, những gì compiler làm cho bạn là vô ích (và tốn thêm chút thời gian xử lý): giá trị bạn gán trong initializer được tạo ra và xóa bỏ ngay lập tức. Do vậy, nếu trong mỗi constructor bạn cần khởi tạo một giá trị khác nhau, bạn không nên sử dụng initializer nữa.
Tình huống thứ ba khá là hiển nhiên. Code của initializer chỉ có thể là các lệnh khởi tạo giá trị cơ bản. Bạn không thể viết các logic phức tạp ở đó, bao gồm cả việc xử lý ngoại lệ.
Khởi tạo giá trị cho thành viên static
Đối với các thành viên static, tình huống hơi phức tạp hơn một chút. Bạn NÊN khởi tạo giá trị cho thành viên static TRƯỚC khi tạo bất kỳ object nào của class. Với yêu cầu này, bạn KHÔNG NÊN sử dụng hàm tạo thông thường cũng như bất kỳ phương thức thành viên bình thường để khởi tạo giá trị cho các thành viên static.
Để phân biệt, người ta dùng thuật ngữ static member và instance member. Instance member là những thành viên truy xuất qua object. Nghĩa là chúng chỉ sử dụng được khi bạn khai báo và khởi tạo object (biến) của class tương ứng. Static member được truy xuất qua tên class và không liên quan đến object. Bạn có thể đọc lại bài viết về thành viên static trong class để hiểu rõ hơn.
Do đó trong C# bạn cần dùng bộ khởi tạo hoặc hàm tạo static để khởi tạo giá trị cho thành viên static.
Trước hết chúng ta nói sơ qua về hàm tạo static (static constructor). Đây là loại hàm tạo đặc biệt với từ khóa static:
public class SampleClass { public static SampleClass() { } }
Hàm tạo static được chạy ngay trước khi bất kỳ thành viên nào của class được truy xuất lần đầu tiên. Do đó, nếu bạn cần khởi tạo thành viên static với logic phức tạp, hãy thực hiện trong static constructor.
Tương tự như đã phân tích ở phần trên, nếu việc khởi tạo thành viên static không đòi hỏi logic phức tạp, bạn nên sử dụng initializer để đảm bảo mọi thành viên static đều được khởi tạo mà không cần đến static constructor, cũng như tránh sự thiếu đồng bộ giữa khai báo và khởi tạo.
Initializer cho các thành viên static cũng hoạt động với cùng logic như initializer cho thành viên thông thường. Nghĩa là C# compiler cũng copy các đoạn code khởi tạo và đặt vào đầu static constructor.
Initializer cho constructor – chuỗi constructor
Ở trên chúng ta mới chỉ phân tích tình huống khởi tạo giá trị cho thành viên nhưng không nhận giá trị từ bên ngoài. Do đó bạn chỉ cần sử dụng constructor không tham số hoặc constructor mặc định.
Tuy nhiên một tình huống rất thường xuyên gặp là khi khởi tạo giá trị bạn phải nhận giá trị từ bên ngoài (client code).
Constructor có tham số
Hãy xem đoạn code sau:
public class SampleClass { private string _firstName; private string _lastName; private string _address; private string _email; private DateTime _dateOfBirth; public SampleClass() { } }
Trong class này có rất nhiều biến thành viên. Khi người sử dụng class này để khởi tạo object, bạn muốn người ta phải truyền giá trị tương ứng để khởi tạo các biến thành viên.
Bạn có muốn xây dựng một constructor như thế này không?
public SampleClass(string firstName, string lastName, string address, string email, DateTime dateOfBirth) { _firstName = firstName; _lastName = lastName; _address = address; _email = email; _dateOfBirth = dateOfBirth; }
Bạn đang bắt buộc người dùng class phải cung cấp tất cả tham số để khởi tạo object. Từ phía người dùng class (client code), một constructor với số lượng tham số lớn như vậy rất khó sử dụng. Hơn nữa, không phải lúc nào người ta cũng muốn (và có) đủ dữ liệu để khởi tạo.
Do vậy, bạn không nên xây dựng MỘT constructor khổng lồ. Thay vào đó bạn nên xây dựng một chuỗi constructor, với lượng tham số tăng dần. Nói cách khác, bạn nên tạo ra nhiều overload khác nhau cho constructor để hỗ trợ người dùng class.
Chuỗi constructor
Chuỗi constructor có nghĩa là, bạn xây dựng một constructor đầu tiên chỉ đòi hỏi firstName và lastName, Constructor thứ hai đòi hỏi THÊM address, Constructor thứ ba cần thêm email, v.v..
Việc xây dựng chuỗi constructor như vậy giúp việc khởi tạo object đơn giản hơn.
Tuy nhiên, khi xây dựng chuỗi constructor, chúng tôi thấy nhiều bạn mắc phải LỖI như sau:
// chú ý, đây là code không hợp lý public SampleClass(string firstName, string lastName) { _firstName = firstName; _lastName = lastName; } public SampleClass(string firstName, string lastName, string address) { _firstName = firstName; _lastName = lastName; _address = address; } public SampleClass(string firstName, string lastName, string address, string email) { _firstName = firstName; _lastName = lastName; _address = address; _email = email; } public SampleClass(string firstName, string lastName, string address, string email, DateTime dateOfBirth) { _firstName = firstName; _lastName = lastName; _address = address; _email = email; _dateOfBirth = dateOfBirth; }
Bạn có để ý thấy là mình đang lặp code giữa các constructor không ạ. Bạn copy thân của constructor trước đó và paste vào thân constructor tiếp theo trong chuỗi.
Bạn hẳn có thể đưa ra “giải pháp” để chống lặp code: gọi constructor khác trong THÂN của constructor này:
// KHÔNG NÊN sử dụng phương pháp này public SampleClass(string firstName, string lastName) { _firstName = firstName; _lastName = lastName; } public SampleClass(string firstName, string lastName, string address) { SampleClass(firstName, lastName); _address = address; } public SampleClass(string firstName, string lastName, string address, string email) { SampleClass(firstName, lastName, address); _email = email; }
Tuy nhiên bạn KHÔNG NÊN sử dụng cách thức này. Trong tình huống này C# compiler sẽ dịch ra kết quả rất tệ. Các constructor ở cuối chuỗi đòi hỏi hàng loạt lời gọi đến các constructor trước đó mới hoàn thành được nhiệm vụ của mình.
Sử dụng constructor initializer
C# cho phép ghép nối các constructor (constructor này gọi constructor khác) thành một chuỗi qua một cú pháp gọi là constructor initializer.
Hãy cùng điều chỉnh code trên như sau:
public SampleClass(string firstName, string lastName) { _firstName = firstName; _lastName = lastName; } public SampleClass(string firstName, string lastName, string address) : this(firstName, lastName) { _address = address; } public SampleClass(string firstName, string lastName, string address, string email) : this(firstName, lastName, address) { _email = email; } public SampleClass(string firstName, string lastName, string address, string email, DateTime dateOfBirth) :this(firstName, lastName, address, email) { _dateOfBirth = dateOfBirth; }
Cấu trúc this(…) cho phép bạn “gọi” đến constructor khác và cung cấp tham số cho nó.
Trên thực tế, khi sử dụng this, code của constructor được gọi sẽ được copy vào thân của constructor gọi. Khác biệt là bạn đẩy việc copy code giữa các constructor vào tay C# compiler. Do đó, chuỗi constructor sử dụng initializer (this) hoạt động không có gì khác biệt so với khi bạn tự mình copy code giữa các constructor.
Phương pháp này tạo ra các constructor tối ưu hơn nhiều so với việc gọi constructor trong thân constructor khác.
Sử dụng tham số mặc định
Mục đích của việc xây dựng chuỗi constructor như trên thực ra chỉ là để người dùng có nhiều lựa chọn khi khởi tạo object.
Bạn cũng có thể đạt được hiệu quả gần tương tự nếu sử dụng tham số mặc định (default parameter) cho hàm tạo.
Tham số mặc định xuất hiện từ C# 4.0. Nó báo hiệu rằng bạn không nhất thiết phải truyền giá trị cho tham số này. Trong trường hợp bạn không truyền giá trị, tham số đó sẽ nhận giá trị bạn thiết lập sẵn từ trước.
Hãy cùng chỉnh lại các constructor của SampleClass như sau:
public SampleClass(string firstName, string lastName, string address = "Washington DC", string email = "trump@gmail.com", string dateOfBirth = DateTime.Today) { _firstName = firstName; _lastName = lastName; _address = address; _email = email; _dateOfBirth = dateOfBirth; }
Trong constructor này, chỉ có firstName và lastName là bắt buộc khi khởi tạo object. Các tham số còn lại đều là tùy chọn, nghĩa là bạn tự quyết xem có truyền giá trị hay không. Do đó, tham số mặc định cũng thường được gọi là tham số tùy chọn (optional parameter).
Khi này người khởi tạo object của SampleClass hoàn toàn có thể viết như sau:
var trump = new SampleClass("Donald", "Trump"); var trump2 = new SampleClass("Donald", "Trump", "New York"); var trump3 = new SampleClass("Donald", "Trump", "Moscow", "trump@mail.ru");
Nghĩa là nó đem lại hiệu quả đối với người sử dụng tương tự như chuỗi constructor.
Kết luận
Trong bài viết này chúng ta đã trao đổi một số kinh nghiệm liên quan đến việc khởi tạo thành viên của class và cách sử dụng constructor. Cụ thể, bạn đã hiểu cách thức hoạt động của initializer đối với thành viên của class. Bạn đã biết khi nào nên và không nên sử dụng initializer. Bạn cũng hiểu cách thức ghép nối constructor thành chuỗi với constructor initializer.
Hy vọng bài viết sẽ có ích cho bạn.
+ Nếu bạn thấy site hữu ích, trước khi rời đi hãy giúp đỡ site bằng một hành động nhỏ để site có thể phát triển và phục vụ bạn tốt hơn.
+ Nếu bạn thấy bài viết hữu ích, hãy giúp chia sẻ tới mọi người.
+ Nếu có thắc mắc hoặc cần trao đổi thêm, mời bạn viết trong phần thảo luận cuối trang.
Cảm ơn bạn!