Chương 8: Vấn đề khác về lớp
Tác giả: Sưu tầm
Khái quát
Chương này sẽ thảo luận một số vấn đề linh tinh liên quan đến lớp, bao gồm cấu tử, sự lồng nhau, và các luật chồng hàm.
Lớp lồng nhau
Đôi khi, thật thuận tiện để lồng các lớp bên trong các lớp khác, như khi một lớp giúp đỡ chỉ được sử dụng bởi một lớp khác. Khả năng truy xuất của một lớp lồng nhau tuân theo những quy tắc tương tự cho một lớp đã phác thảo cho sự tương tác của các bổ từ lớp và thành phần. Như với các thành phần, bổ từ truy xuất trong một lớp lồng nhau định nghĩa khả năng truy xuất lớp lồng nhau từ bên ngoài lớp lồng nhau. Cũng như một trường private luôn luôn thấy được bên trong một lớp, một lớp lồng nhau private là cũng thấy được từ bên trong lớp chứa nó.
Trong ví dụ sau đây, lớp Parser có một lớp Token ngay trong nội tại. Nếu không sử dụng một lớp lồng nhau, ta có thể viết như sau:
public class Parser {
Token[] tokens;
}
public class Token {
string name;
}
Trong ví dụ này, cả hai lớp Parser và Token có thể truy xuất public, điều này là không tối ưu. Lớp Token không chỉ là một lớp chiếm nhiều không gian của người thiết kế trong việc liệt kê các lớp, mà còn không được thiết kế để có lợi ích chung. Do đó là hữu ích khi viết thành một lớp lồng nhau, mà sẽ cho phép nó được khai báo với khả năng truy xuất private, che giấu nó với tất cả các lớp ngoại trừ Parser.
Đây là mã đã được sửa lại:
public class Parser {
Token[] tokens;
private class Token {
string name;
}
}
Bây giờ, không có ai có thể thấy Token. Một cách khác là viết lớp Token thành một lớp internal, để nó không được thấy từ bên ngoài một hợp tập (assembly), nhưng với giải pháp này, nó vẫn được thấy bên trong hợp tập.
Giải pháp này cũng làm mất đi một lợi ích quan trọng trong việc sử dụng lớp lồng nhau. Một lớp lồng nhau làm cho nó rất rõ ràng trong việc đọc mã nguồn mà lớp Token có thể được bỏ qua một cách an toàn trừ khi những cái bên trong của Parser là quan trọng. Nếu cách tổ chức này là được áp dụng trong toàn bộ một hợp tập, nó có thể giúp đơn giản hoá mã đáng kể.
Việc lồng nhau cũng có thể được sử dụng như một đặc tính tổ chức. Nếu lớp Parser ở bên trong không gian tên Language, bạn có thể yêu cầu một không gian tên riêng biệt là Parser để tổ chức tốt các lớp cho Parser, và không gian tên đó sẽ chứa lớp Token và một lớp Parser đã được đổi tên. Bằng việc sử dụng các lớp lồng nhau, lớp Parser có thể được để lại trong không gian tên Language, và chứa đựng một lớp Token.
Việc lồng nhau khác
Các lớp không chỉ là kiểu duy nhất có thể lồng nhau; các giao diện, cấu trúc, và kiểu liệt kê cũng có thể được lồng vào trong một lớp.
Khai báo, khởi tạo, huỷ bỏ
Trong bất kỳ hệ thống hướng đối tượng nào, việc tìm hiểu các vấn đề khai báo, khởi tạo, và huỷ bỏ các đối tượng là rất quan trọng. Trong thời gian chạy .NET, người lập trình không thể điều khiển huỷ bỏ các đối tượng, nhưng là có ích để biết phạm vi có thể được điều khiển.
Cấu tử
Trong C#, không có cấu tử mặc định được tạo cho các đối tượng. Đối với các lớp, một cấu tử mặc định (ví dụ, không tham số) có thể được viết nếu cần.
Một cấu tử có thể gọi đến một cấu tử của kiểu cơ sở bằng cách sử dụng cú pháp base:
using System;
public class BaseClass {
public BaseClass(int x) {
this.x = x;
}
public int X {
get {
return(x);
}
}
int x;
}
public class Derived: BaseClass {
public Derived(int x): base(x) {
}
}
class Test {
public static void Main() {
Derived d = new Derived(15);
Console.WriteLine(“X = {0}”, d.X);
}
}
Trong ví dụ này, cấu tử của lớp Derived đơn thuần hướng tới cấu tử của lớp BaseClass.
Đôi khi là có ích cho một cấu tử khi hướng đến một cấu tử khác trong cùng một đối tượng.
using System;
class MyObject {
public MyObject(int x) {
this.x = x;
}
public MyObject(int x, int y): this(x) {
this.y = y;
}
public int X {
get {
return(x);
}
}
public int Y {
get {
return(y);
}
}
int x;
int y;
}
class Test {
public static void Main() {
MyObject my = new MyObject(10, 20);
Console.WriteLine(“x = {0}, y = {1}”, my.X, my.Y);
}
}
Khởi tạo
Nếu giá trị mặc định của một trường không mong muốn, nó có thể được thiết đặt trong cấu tử. Nếu có nhiều cấu tử cho một đối tượng, có thể là tiện lợi hơn – và giảm đi lỗi dễ mặc phải – khi thiết đặt giá trị thông qua một khởi tạo hơn là việc thiết đặt trong mỗi cấu tử.
Đây là một ví dụ của cách làm việc của một khởi tạo:
public class Parser {
public Parser(int number) {
this.number = number;
}
int number;
}
class MyClass {
public int counter = 100;
public string heading = “Top”;
private Parser parser = new Parser(100);
}
Đây là một thuận lợi thú vị; các giá trị khởi tạo có thể được thiết đặt khi một thành phần được khai báo. Nó cũng làm cho một lớp dễ bảo trì hơn, vì nó rõ ràng hơn cái gì là giá trị khởi tạo của một thành phần.
Mẹo Như một quy tắc chung, nếu một thành phần có những giá trị khác nhau phụ thuộc vào cấu tử nào được sử dụng, thì giá trị trường nên được thiết đặt trong cấu tử. Nếu giá trị là được đặt trong khi khởi tạo, thì có thể không được rõ ràng khi một thành phần có thể có một giá trị khác sau một lời gọi cấu tử.
Huỷ tử
Nói một cách chính xác, C# không có các huỷ tử, ít nhất là không trong cách mà mọi người nghĩ về các huỷ tử, là nơi mà huỷ tử được gọi khi đối tượng được xoá.
Cái gì được biết như một huỷ tử trong C# được biết như một finalizer trong một số ngôn ngữ khác, và được gọi bởi trình gom rác khi một đối tượng được xoá. Điều này có nghĩa là người lập trình không điều khiển trực tiếp khi nào huỷ tử được gọi, và do đó ít hữu ích hơn trong các ngôn ngữ như C#. Nếu việc dọn dẹp được thực hiện, cũng có một phương pháp khác thực hiện cùng thao tác để người sử dụng có thể điều khiển trực tiếp tiến trình.
Để có nhiều thông tin hơn về vấn đề này, hãy xem mục về trình gom rác trong Chương 31, “Xâu hơn với C#”.
Chồng hàm và che hàm
Trong các lớp C# – và trong Common Language Runtime nói chung – các thành phần được chồng dựa trên số lượng và kiểu của các tham số. Chúng không được chồng dựa trên kiểu trả về của một hàm.
// error
using System;
class MyObject {
public string GetNextValue(int value) {
return((value + 1).ToString());
}
public int GetNextVa ue(int value) {
return(value + 1);
}
}
class Test {
public static void Main() {
MyObject my = new MyObject();
Console.WriteLine(“Next: {0}”, my.GetNextValue(12));
}
}
Đoạn mã này không biên dịch được bởi vì các chồng hàm GetNextValue() chỉ khác nhau ở kiểu trả về, và trình biên dịch không thể xác định được hàm nào được gọi. Do đó đó là một lỗi khi khai báo các hàm chỉ khác nhau ở kiểu tra về.
Che hàm
Trong C#, các tên phương thức được che dấu dựa trên tên của một phương thức, hơn là trên dấu hiệu của phương thức đó. Hãy xem xét ví dụ sau:
// error
using System;
public class Base {
public int Process(int value) {
Console.WriteLine(“Base.Process: {0}”, value);
}
public class Derived: Base {
public int Process(string value) {
Console.WriteLine(“Derived.Process: {0}”, value);
}
}
class Test {
public static void Main() {
Derived d = new Derived();
d.Process(“Hello”);
d.Process(12); // error
((Base) d).Process(12); // okay
}
}
Nếu có hai chồng hàm Process() trong cùng một lớp, thì chúng đều có thể được truy xuất. Bởi vì chúng là trong các lớp khác nhau, định nghĩa của Process() trong lớp thừa kế che dấu tất cả các sử dụng hàm cùng tên trong lớp cơ sở.
Để có thể truy xuất đến cả hai hàm, Derived cần chồng một phiên bản của Process() chứa trong lớp cơ sở, và sau đó hướng lời gọi đến cài đặt của lớp cơ sở.
Ngược lại, các kiểu trả về hiệp biến C++ là không được hỗ trợ.
Các trường tĩnh
Đôi khi có ích để định nghĩa các thành phần của một đối tượng mà không kết hợp với một thể hiện nhất định của một lớp, nhưng với tất cả các thể hiện của lớp. Các thành phần như vậy được biết như các thành phần tĩnh.
Một trường tĩnh là thành phần tĩnh đơn giản nhất; để khai báo một trường tĩnh, đơn giản đặt bổ từ static trước khai báo biến. Ví dụ, kỹ thuật sau đây có thể được dùng để theo dõi số lượng thể hiện của một lớp đã được tạo.
using System;
class MyClass {
public MyClass() {
instanceCount++;
}
public static int instanceCount = 0;
}
class Test {
public static void Main() {
MyClass my = new MyClass();
Console.WriteLine(MyClass.instanceCount);
MyClass my2 = new MyClass();
Console.WriteLine(MyClass.instanceCount);
}
}
Cấu tử cho đối tượng tăng biến đếm đối tượng, và biến đếm đối tượng có thể được tham chiếu để xác định có bao nhiêu thể hiện của đối tượng đã được tạo. Một trường tĩnh được truy xuất thông qua tên của lớp hơn là thông qua một thể hiện của lớp; điều đó là đúng cho tất cả các thành phần tĩnh.
Chú ý Điều này không giống hành vi trong C++ ngôn ngữ mà thành phần tĩnh có thể được truy xuất hoặc thông qua tên lớp hoặc thông qua tên của một thể hiện. Trong C++, điều này dẫn đến một số vấn đề có thể đọc được, như đôi khi là không rõ ràng từ mã dù một truy xuất là tĩnh hay thông qua một thể hiện.
Các hàm thành phần tĩnh
Ví dụ trước trình bày một trường internal, thường là một số điều được tránh. Nó có thể được cấu trúc lại để sử dụng một hàm thành phần tĩnh thay vì một trường tĩnh:
using System;
class MyClass {
public MyClass() {
instanceCount++;
}
public static int GetInstanceCount() {
return(instanceCount);
}
static int instanceCount = 0;
}
class Test {
public static void Main() {
MyClass my = new MyClass();
Console.WriteLine(MyClass.GetInstanceCount());
}
}
Đây là một điều đúng, và không còn trình bày một trường cho người sử dụng lớp, mà nó tăng tính linh động tương lai. Bởi vì nó là một hàm thành phần tĩnh, nó được gọi bằng cách sử dụng tên của lớp hơn là tên của một thể hiện.
Trong thế giới thực, ví dụ này có thể được viết tốt hơn bằng cách sử dụng một thuộc tính tĩnh, được thảo luận ở Chương 18, “Thuộc tính”.
Các cấu tử tĩnh
Đúng như chỗ đó có thể là các thành phần tĩnh khác, ở đó có thể là một cấu tử tĩnh. Một cấu tử tĩnh sẽ được gọi trước khi thể hiện đầu tiên của đối tượng được tạo và là hữu ích để làm công việc cài đặt cần thiết được làm chỉ một lần.
Chú ý Giống như nhiều thứ khác trong .NET Runtime, người sử dụng không điều khiển cấu tử tĩnh được gọi; thời gian chạy chỉ bảo đảm rằng đôi khi nó được gọi sau khi khởi động chương trình và trước khi thể hiện đầu tiên của đối tượng được tạo. Điều này đặc biệt có nghĩa là nó không được xác định trong cấu tử tĩnh mà một thể hiện sắp sửa được tạo ra.
Một cấu tử tĩnh được khai báo một cách đơn giản bằng cách thêm bổ từ static trước định nghĩa cấu tử. Một cấu tử tĩnh không thể có bất kỳ tham số nào.
class MyClass {
static MyClass(){
}
}
Không có huỷ tử tĩnh tương tự một huỷ tử.
Các hằng số
C# cho phép các giá trị được định nghĩa là các hằng số. Đối với một giá trị là một hằng số, giá trị của nó phải một số thứ có thể được viết như một hằng. Điều này giới hạn các kiểu của hằng cho các kiểu dựng sẵn mà có thể được viết như các giá trị bình thường.
Không đáng ngạc nhiên, việc đặt const trước một biến có nghĩa là giá trị của nó không thể thay đổi. Đây là một ví dụ về một số hằng:
using System;
enum MyEnum {
Jet
}
class LotsOLiterals {
// const items can’t be changed.
// const implies static.
public const int value1 = 33;
public const string value2 = “Hello”;
public const MyEnum value3 = MyEnum.Jet;
}
class Test {
public static void Main() {
Console.WriteLine(“{0} {1} {2}”,
LotsOLiterals.value1,
LotsOLiterals.value2,
LotsOLiterals.value3);
}
}
Các trường readonly
Bởi vì hạn chế trên các kiểu hằng là có thể được biết tại thời gian biên dịch, const không được sử dụng trong vài tình huống.
Trong lớp Color, nó có thể rất hữu ích khi có các hằng như một phần của lớp cho các màu chung. Nếu không có các hạn chế trên const, ví dụ sau sẽ làm việc:
// error
class Color {
public Color(int red, int green, int blue) {
this.red = red;
this.green = green;
this.blue = blue;
}
int red;
int green;
int blue;
// call to new can’t be used with static
public static const Color Red = new Color(255, 0, 0);
public static const Color Green = new Color(0, 255, 0);
public static const Color Blue = new Color(0, 0, 255);
}
class Test {
static void Main() {
Color background = Color.Red;
}
}
Nó rõ ràng sẽ không làm việc, vì các thành phần tĩnh Red, Green, và Blue không thể được tính toán tại thời gian biên dịch. Nhưng việc viết chúng thành các thành phần công bình thường không làm việc hoặc, vì một người có thể thay đổi giá trị màu đỏ thành màu lục vàng, hoặc màu sẩm.
Bổ từ readonly được thiết kế cho trường hợp này. Bằng cách áp dụng readonly, giá trị có thể được thiết đặt trong cấu tử hay trong khi khởi tạo, nhưng không thể được sửa đổi sau đó.
Bởi vì các giá trị màu thuộc về một lớp và không là một thể hiện cụ thể của lớp, nhưng sẽ được khởi tạo trong một cấu tử tĩnh.
class Color {
public Color(int red, int green, int blue) {
this.red = red;
this.green = green;
this.blue = blue;
}
int red;
int green;
int blue;
public static readonly Color Red;
public static readonly Color Green;
public static readonly Color Blue;
// static constructor
static Color() {
Red = new Color(255, 0, 0);
Green = new Color(0, 255, 0);
Blue = new Color(0, 0, 255);
}
}
class Test {
static void Main() {
Color background = Color.Red;
}
}
Điều này cung cấp một hành vi đúng.
Nếu số lượng các thành phần tĩnh là lớn và việc tạo chúng là tốn kém (hoặc thời gian hoặc bộ nhớ), nó có thể có ý nghĩa hơn để khai báo chúng như các thuộc tính readonly, để các thành phần có thể được xây dựng khi cần.
Mặt khác, có thể dễ dàng hơn để định nghĩa một sự liệt kê các tên màu khác nhau và trả về các thể hiện của các giá trị khi cần:
class Color {
public Color(int red, int green, int blue) {
this.red = red;
this.green = green;
this.blue = blue;
}
public enum PredefinedEnum {
Red,
Blue,
Green
}
public static Color GetPredefinedColor(PredefinedEnum pre) {
switch (pre) {
case PredefinedEnum.Red: return(new Color(255, 0, 0));
case PredefinedEnum.Green: return(new Color(0, 255, 0));
case PredefinedEnum.Blue: return(new Color(0, 0, 255));
default: return(new Color(0, 0, 0));
}
}
int red;
int blue;
int green;
}
class Test {
static void Main() {
Color background = Color.GetPredefinedColor(Color.PredefinedEnum.Blue);
}
}
Điều này đòi hỏi không nhiều thuật đánh máy để sử dụng, nhưng ở đây không có một khởi động bất lợi hay nhiều đối tượng chiếm chỗ. Nó cũng giữ một giao diện lớp đơn giản; nếu có 30 thành phần cho các màu định trước, lớp sẽ phức tạp hơn để hiểu.
Chú ý Các lập trình C++ có kinh nghiệm có lẽ cảm thấy khó chịu tại dòng mã cuối cùng trong ví dụ. Nó thể hiện một trong những vấn đề cổ điển với cách C++ tiếp xúc với việc quản lý bộ nhớ. Việc chuyển trở lại một đối tượng đã được định vị có nghĩa là trình gọi phải giải phóng nó. Điều này là khá dễ đối với người sử dụng lớp để hoặc quên giải phóng đối tượng hay làm mất con trỏ đến đối tượng, mà nó dẫn đến sự rò rỉ bộ nhớ. Tuy nhiên, trong C#, đây không phải là vấn đề, bởi vì thời gian chạy xử lý sự định vị bộ nhớ. Trong ví dụ trước, một đối tượng được tạo trong hàm Color.GetPredefinedColor() ngay lập tức sao chép cho biến background và sau đó có thể được sưu tập.
Các cấu tử private
Bởi vì không có các biến hay hằng toàn cục trong C#, tất cả các khai báo phải được đặt bên trong một lớp. Đôi khi điều này dẫn đến các lớp được tạo nên hoàn toàn từ các thành phần tĩnh. Trong trường hợp này, không có lý do nào để thậm chí khởi tạo một đối tượng của lớp, và điều đó có thể được ngăn chặn bằng cách thêm một cấu tử tĩnh vào lớp.
// Error
using System;
class PiHolder {
private PiHolder() {}
static double Pi = 3.1415926535;
}
class Test {
PiHolder pi = new PiHolder(); // error
}
Mặc dù việc thêm private trước định nghĩa cấu tử không làm thay đổi khả năng truy xuất thực sự của cấu tử, việc viết nó một cách tường minh làm cho nó rõ ràng mà lớp được mong đợi để có một cấu tử tĩnh.
Danh sách tham số độ dài biến
Đôi khi là hữu dụng để định nghĩa một tham số để lấy số lượng biến của các tham số (WriteLine() là một ví dụ tốt). C# cho phép hỗ trợ như vậy để dễ dàng thêm vào:
using System;
class Port {
// version with a single object parameter
public void Write(string label, object arg) {
WriteString(label);
WriteString(arg.ToString());
}
// version with an array of object parameters
public void Write(string label, params object[] args)) {
WriteString(label);
for (int index = 0; index < args.GetLength(0); index++) {
WriteString(args[index].ToString());
}
}
void WriteString(string str) {
// writes string to the port here
Console.WriteLine(“Port debug: {0}”, str);
}
}
class Test {
public static void Main() {
Port port = new Port();
port.Write(“Single Test”, “Port ok”);
port.Write(“Port Test: “, “a”, “b”, 12, 14.2);
object[] arr = new object[4];
arr[0] = “The”;
arr[1] = “answer”;
arr[2] = “is”;
arr[3] = 42;
port.Write(“What is the answer?”, arr);
}
}
Từ khoá params trong tham số cuối thay đổi cách trình biên dịch tìm kiếm các hàm. Khi nó bắt gặp một lời gọi đến hàm đó, đầu tiên nó kiểm tra xem có một so khớp chính xác đến hàm đó không. Lời gọi hàm đầu tiên đối sánh với:
public void Write(string, object arg)
Tương tự, hàm thứ ba truyền một mảng object, và nó đối sánh với:
public void Write(string label, params object[] args)
Bây giờ hãy quan tâm đến lời gọi thứ hai. Một định nghĩa với một tham số object không đối sánh, nhưng cũng không đối sánh với hàm có một mảng object.
Khi cả hai đối sánh này bị lỗi, trình biên dịch chú ý đến sự hiện diện của từ khoá params, và sau đó nó cố gắng đối sánh danh sách tham số bằng cách di chuyển lại một phần mảng của tham số params và sao chép tham số đó cho đến khi có cùng số lượng tham số.
Nếu điều này dẫn đến một hàm đối sánh, sau đó nó ghi mã để tạo một mảng đối tượng. Mặt khác, dòng
port.Write(“Port Test: “, “a”, “b”, 12, 14.2);
được ghi lại như sau
object[] temp = new object[4];
temp[0] = “a”;
temp[1] = “b”;
temp[2] = 12;
temp[3] = 14.2;
port.Write(“Port Test: “, temp);
Trong ví dụ này, tham số params là một mảng object, nhưng nó có thể là một mảng của bất kỳ kiểu gì.
Ngoài phiên bản dùng một mảng, nó thường có ý nghĩa để cung cấp một hay nhiều phiên bản đặc biệt của một hàm. Điều này là hữu ích cả cho tính hiệu quả (nên một mảng object không cần phải được khởi tạo), và để các ngôn ngữ không hỗ trợ cú pháp params không phải sử dụng mảng object cho tất cả các lời gọi. Việc chồng một hàm với các phiên bản mà có một, hai, và ba tham số, cộng thêm một phiên bản có một mảng, là một kinh nghiệm tốt.