Chương 6: Lớp cơ sở và sự thừa kế
Tác giả: Sưu tầm
Khái quát
Như đã thảo luận trong Chương 1, “Cơ sở hướng đối tượng”, đôi khi ta thực hiện dẫn xuất một lớp từ một lớp khác, nếu lớp dẫn xuất là một minh dụ của lớp cơ sở.
Lớp Engineer
Lớp sau đây cài đặt một đối tượng Engineer và những phương thức để tính tiền công cho đối tượng Engineer này.
using System;
class Engineer {
// hàm thiết lập
public Engineer(string name, float billingRate) {
this.name = name;
this.billingRate = billingRate;
}
// tính tiền công dựa trên hiệu suất kỹ sư
public float CalculateCharge(float hours) {
return(hours * billingRate);
}
// trả ra tên của loại này
public string TypeName() {
return(“Engineer”);
}
private string name;
protected float billingRate;
}
class Test {
public static void Main() {
Engineer engineer = new Engineer(“Hank”, 21.20F);
Console.WriteLine(“Name is: {0}”, engineer.TypeName());
}
}
Engineer sẽ phục vụ như một lớp cơ sở cho kịch bản này. Nó chứa một trường riêng tư name, và một trường được bảo vệ billingRate. Bổ từ protected mang lại khả năng truy xuất cũng như bổ từ private, chỉ có điều những lớp mà được dẫn xuất từ lớp này cũng có thể truy xuất trường này. Do đó, protected được sử dụng để cho những lớp dẫn xuất từ một lớp truy xuất tới một trường của lớp đó.
Bổ từ protected cho phép những lớp khác phụ thuộc vào sự thi hành bên trong của lớp, và bởi vậy chỉ nên cung cấp khi cần thiết. Trong ví dụ, thành phần billingRate không thể được đổi tên, vì lớp dẫn xuất có thể truy xuất nó. Đây thường là một sự lựa chọn thiết kế tốt hơn để sử dụng một thuộc tính được bảo vệ.
Lớp Engineer cùng có một hàm thành phần có thể được sử dụng để tính tiền công dựa vào số giờ làm việc.
Đơn thừa kế
CivilEngineer là một loại kỹ sư, và do đó có thể dẫn xuất từ lớp Engineer:
using System;
class Engineer {
public Engineer(string name, float billingRate) {
this.name = name;
this.billingRate = billingRate;
}
public float CalculateCharge(float hours) {
return(hours * billingRate);
}
public string TypeName() {
return(“Engineer”);
}
private string name;
protected float billingRate;
}
class CivilEngineer: Engineer {
public CivilEngineer(string name, float billingRate) :base(name,
billingRate){}
// hàm mới, bởi vì nó khác so với
// phiên bản cơ sở
public new float CalculateCharge(float hours) {
if (hours < 1.0F)
hours = 1.0F; // tiền công thấp nhất.
return(hours * billingRate);
}
// hàm mới, bởi vì nó khác so với
// phiên bản cơ sở
public new string TypeName() {
return(“Civil Engineer”);
}
}
class Test {
public static void Main() {
Engineer e = new Engineer(“George”, 15.50F);
CivilEngineer c = new CivilEngineer(“Sir John”, 40F);
Console.WriteLine(“{0} charge = {1}”,e.TypeName(),e.CalculateCharge(2F));
Console.WriteLine(“{0} charge = {1}”,c.TypeName(),c.CalculateCharge(0.75F));
}
}
Bởi vì lớp CivilEngineer dẫn xuất từ Engineer, nên nó thừa kế tất cả các thành phần dữ liệu của lớp này (mặc dù thành phần name không thể truy xuất được, bởi vì nó là riêng tư), và nó cũng thừa kế hàm thành phần CalculateCharge().
Hàm thiết lập không thể được thừa kế, vì vậy một hàm thiết lập riêng được viết cho lớp CivilEngineer. Hàm thiết lập không làm bất cứ việc gì đặc biệt, nên nó gọi đến hàm thiết lập của Engineer, bằng cách sử dụng cú pháp base. Nếu lời gọi đến hàm thiết lập của lớp bằng cú pháp base bị bỏ sót, trình biên dịch sẽ gọi hàm thiết lập không tham số của lớp cơ sở.
CivilEngineer có một cách khác để tính tiền công; tiền công thấp nhất là 1 giờ cho mỗi lần, nên ta có phiên bản mới của CalculateCharge().
Khi thực hiện ví dụ, ta có kết quả sau:
Engineer Charge = 31
Civil Engineer Charge = 40
Mảng Engineer
Mọi việc là đều tốt đẹp, khi chỉ có một vài nhân viên. Khi một công ty phát triển, thì bạn sẽ tiếp xúc với vấn đề mảng các kỹ sư.
Bởi vì CivilEngineer được dẫn xuất từ Engineer, nên một mảng kiểu Engineer có thể lưu trữ cả hai kiểu. Ví dụ này có một hàm Main() khác, chúng đặt các kỹ sư vào trong một mảng:
using System;
class Engineer {
public Engineer(string name, float billingRate) {
this.name = name;
this.billingRate = billingRate;
}
public float CalculateCharge(float hours) {
return(hours * billingRate);
}
public string TypeName() {
return(“Engineer”);
}
private string name;
protected float billingRate;
}
class CivilEngineer: Engineer {
public CivilEngineer(string name, float billingRate) :base(name, billingRate){}
public new float CalculateCharge(float hours) {
if (hours < 1.0F)
hours = 1.0F; // tiền công thấp nhất.
return(hours * billingRate);
}
public new string TypeName() {
return(“Civil Engineer”);
}
}
class Test {
public static void Main() {
// tạo một mảng kiểu Engineer
Engineer[] earray = new Engineer[2];
earray[0] = new Engineer(“George”, 15.50F);
earray[1] = new CivilEngineer(“Sir John”, 40F);
Console.WriteLine(“{0} charge = {1}”, earray[0].TypeName(),
earray[0].CalculateCharge(2F));
Console.WriteLine(“{0} charge = {1}”, earray[1].TypeName(),
earray[1].CalculateCharge(0.75F));
}
}
Phiên bản này cho ra kết quả là:
Engineer Charge = 31
Engineer Charge = 30
Điều này là không đúng.
Khi các kỹ sư được đặt vào trong mảng, thật sự thì kỹ sư thứ hai là một CivilEngineer chứ không phải một Engineer. Vì mảng là một mảng Engineer, nên khi CalculateCharge() được gọi, phiên bản của Engineer được gọi thực hiện.
Vậy cần cái gì để xác định chính xác kiểu của một kỹ sư. Điều này có thể thực được bằng cách dùng một trường trong lớp Engineer để chỉ rõ kiểu của nó. Việc viết lại các lớp với một trường enum để chỉ rõ kiểu của kỹ sư như ví dụ sau:
using System;
enum EngineerTypeEnum {
Engineer,
CivilEngineer
}
class Engineer {
public Engineer(string name, float billingRate) {
this.name = name;
this.billingRate = billingRate;
type = EngineerTypeEnum.Engineer;
}
public float CalculateCharge(float hours) {
if (type == EngineerTypeEnum.CivilEngineer) {
CivilEngineer c = (CivilEngineer) this;
return(c.CalculateCharge(hours));
}
else if (type == EngineerTypeEnum.Engineer)
return(hours * billingRate);
return(0F);
}
public string TypeName() {
if (type == EngineerTypeEnum.CivilEngineer) {
CivilEngineer c = (CivilEngineer) this;
return(c.TypeName());
}
else if (type == EngineerTypeEnum.Engineer)
return(“Engineer”);
return(“No Type Matched”);
}
private string name;
protected float billingRate;
protected EngineerTypeEnum type;
}
class CivilEngineer: Engineer {
public CivilEngineer(string name, float billingRate) :base(name, billingRate) {
type = EngineerTypeEnum.CivilEngineer;
}
public new float CalculateCharge(float hours) {
if (hours < 1.0F)
hours = 1.0F; // tiền công thấp nhất.
return(hours * billingRate);
}
public new string TypeName() {
return(“Civil Engineer”);
}
}
class Test {
public static void Main() {
Engineer[] earray = new Engineer[2];
earray[0] = new Engineer(“George”, 15.50F);
earray[1] = new CivilEngineer(“Sir John”, 40F);
Console.WriteLine(“{0} charge = {1}”, earray[0].TypeName(),
earray[0].CalculateCharge(2F));
Console.WriteLine(“{0} charge = {1}”, earray[1].TypeName(),
earray[1].CalculateCharge(0.75F));
}
}
Bằng cách dựa vào trường type, các hàm trong Engineer có thể xác định kiểu thực tế của đối tượng và gọi hàm chính xác.
Kết quả sẽ như mong đợi:
Engineer Charge = 31
Civil Engineer Charge = 40
Thật không may, lớp cơ sở bây giờ đã trở nên phực tạp hơn; vì mỗi hàm còn phải kiểm tra kiểu của lớp, chương trình phải để kiểm tra tất cả các kiểu có thể và gọi hàm đúng. Như thế sẽ có rất nhiều đoạn mã thêm vào, và sẽ không thể bảo vệ được nếu có đến 50 loại kỹ sư.
Tồi tệ hơn là sự thật thì lớp cơ sở cần biết những tên của tất cả các lớp dẫn xuất để nó làm việc. Nếu chủ nhân của chương trình cần hỗ trợ thêm cho một kỹ sư mới, thì lớp cơ sở cần phải được sửa đổi. Nếu một người sử dụng không truy xuất đến lớp cơ sở cần thêm một kiểu kỹ sư mới, thì nó sẽ không làm gì cả.
Hàm ảo
Để làm công việc này dễ dàng, các ngôn ngữ hướng đối tượng cho phép một hàm được xem như ảo. Ảo có nghĩa là khi một lời gọi đến một hàm thành phần được thực hiện, trình biên dịch sẽ tìm kiểu thực của đối tượng đó (không phải chỉ là kiểu của tham chiếu), và gọi hàm chính xác dựa trên kiểu đó.
Với ý tưởng đó, ví dụ có thể được sửa đổi như sau:
using System;
class Engineer {
public Engineer(string name, float billingRate) {
this.name = name;
this.billingRate = billingRate;
}
// bây giờ là hàm ảo
virtual public float CalculateCharge(float hours) {
return(hours * billingRate);
}
// bây giờ là hàm ảo
virtual public string TypeName(){
return(“Engineer”);
}
private string name;
protected float billingRate;
}
class CivilEngineer: Engineer {
public CivilEngineer(string name, float billingRate) :base(name, billingRate){}
// chồng hàm trong Engineer
override public float CalculateCharge(float hours) {
if (hours < 1.0F)
hours = 1.0F; // tiền công thấp nhất.
return(hours * billingRate);
}
// chồng hàm trong Engineer
override public string TypeName() {
return(“Civil Engineer”);
}
}
class Test {
public static void Main() {
Engineer[] earray = new Engineer[2];
earray[0] = new Engineer(“George”, 15.50F);
earray[1] = new CivilEngineer(“Sir John”, 40F);
Console.WriteLine(“{0} charge = {1}”, earray[0].TypeName(),
earray[0].CalculateCharge(2F));
Console.WriteLine(“{0} charge = {1}”, earray[1].TypeName(),
earray[1].CalculateCharge(0.75F));
}
}
Các hàm CalculateCharge() và TypeName() bây giờ được khai báo với từ khoá virtual trong lớp cơ sở, và đó là tất cả những gì lớp cơ sở phải biết. Nó không cần sự hiểu biết của các kiểu dẫn xuất, điều khác hơn để biết là mỗi lớp dẫn xuất có thể thực thi CalculateCharge() và TypeName(), nếu mong muốn. Đối với lớp dẫn xuất, các hàm được khai báo với từ khoá override, có nghĩa là chúng là cùng chức năng được khai báo trong lớp cơ sở. Nếu từ khoá override bị bỏ sót, trình biên dịch sẽ giả thiết rằng hàm là không liên quan đến hàm của lớp cơ sở, và việc gởi đi ảo sẽ không hoạt động.
Ví dụ này cho ra kết quả:
Engineer Charge = 31
Civil Engineer Charge = 40
Khi trình biên dịch gặp một lời gọi hàm TypeName() hay CalculateCharge(), nó chuyển đến định nghĩa của hàm, và nhận thấy rằng đó là một hàm ảo. Thay cho việc phát sinh mã để gọi hàm một cách trực tiếp, nó tạo một đoạn mã liên lạc trong thời gian chạy để tìm kiểu thực của đối tượng, và gọi hàm liên quan với kiểu thực, chứ không phải chỉ là kiểu của tham chiếu. Điều này cho phép hàm đúng sẽ được gọi dù là lớp chưa được cài đặt khi lời gọi được biên dịch.
Có một tổng chi phí nhỏ cho sự liên lạc ảo, nên không nên sử dụng nó trừ khi cần. Tuy nhiên, JIT có thể thông báo rằng không có các lớp dẫn xuất từ lớp trên để một lới gọi hàm được thực hiện, và chuyển đổi sự liên lạc ảo thành một lời gọi thẳng.
Lớp trừu tượng
Có một vấn đề nhỏ với cách tiếp cận được sử dụng cho đến lúc này. Một lớp mới không phải cài đặt hàm TypeName(), vì nò có thể thừa kế phần cài đặt từ Engineer. Điều này làm cho nó dễ dàng cho một lớp mới của kỹ sư để có một tên sai liên quan với nó.
Nếu lớp ChemicalEngineer được thêm vào, ví dụ:
using System;
class Engineer {
public Engineer(string name, float billingRate) {
this.name = name;
this.billingRate = billingRate;
}
virtual public float CalculateCharge(float hours) {
return(hours * billingRate);
}
virtual public string TypeName() {
return(“Engineer”);
}
private string name;
protected float billingRate;
}
class ChemicalEngineer: Engineer {
public ChemicalEngineer(string name, float billingRate):base(name, billingRate) {
}
// các chồng hàm bị bỏ sót một cách sai lầm
}
class Test {
public static void Main() {
Engineer[] earray = new Engineer[2];
earray[0] = new Engineer(“George”, 15.50F);
earray[1] = new ChemicalEngineer(“Dr. Curie”, 45.50F);
Console.WriteLine(“{0} charge = {1}”, earray[0].TypeName(),
earray[0].CalculateCharge(2F));
Console.WriteLine(“{0} charge = {1}”, earray[1].TypeName(),
earray[1].CalculateCharge(0.75F));
}
}
Lớp ChemicalEngineer sẽ thừa kế hàm CalculateCharge() từ Engineer, mà có lẽ là đúng, nhưng nó cũng sẽ thừa kế TypeName(), mà theo định nghĩa là sai. Cái được cần thiết là một cách để bắt buộc ChemicalEngineer để cài đặt TypeName().
Điều này có thể được thực hiện bằng cách thay đổi Engineer từ một lớp bình thường thành một lớp trừu tượng. Trong lớp trừu tượng này, hàm thành phần TypeName() được đánh dấu như một hàm trừu tượng, điều đó có nghĩa là tất cả các lớp mà dẫn xuất từ Engineer sẽ được đòi hỏi cài đặt hàm TypeName().
Một lớp trừu tượng định nghĩa một giao kèo mà các lớp dẫn xuất được mong chờ để tiếp tục. Bởi vì một lớp trừu tượng đang thiếu chức năng “được đòi hỏi”, nên nó không thể được khởi tạo, mà đối với ví dụ này có nghĩa là các thể hiện của lớp Engineer không thể được tạo. Để mà vẫn có hai kiểu phân biệt của các kỹ sư, lớp ChemicalEngineer được thêm vào.
Các lớp trừu tượng hoạt động như các lớp bình thường ngoại trừ một hoặc nhiều hàm thành phần được đánh dấu là trừu tượng.
using System;
abstract class Engineer {
public Engineer(string name, float billingRate) {
this.name = name;
this.billingRate = billingRate;
}
virtual public float CalculateCharge(float hours) {
return(hours * billingRate);
}
abstract public string TypeName();
private string name;
protected float billingRate;
}
class CivilEngineer: Engineer {
public CivilEngineer(string name, float billingRate):base(name, billingRate) {
}
override public float CalculateCharge(float hours) {
if (hours < 1.0F)
hours = 1.0F; // minimum charge.
return(hours * billingRate);
}
override public string TypeName() {
return(“Civil Engineer”);
}
}
class ChemicalEngineer: Engineer {
public ChemicalEngineer(string name, float billingRate):base(name, billingRate) {
}
override public string TypeName() {
return(“Chemical Engineer”);
}
}
class Test {
public static void Main() {
Engineer[] earray = new Engineer[2];
earray[0] = new CivilEngineer(“Sir John”, 40.0F);
earray[1] = new ChemicalEngineer(“Dr. Curie”, 45.0F);
Console.WriteLine(“{0} charge = {1}”, earray[0].TypeName(),
earray[0].CalculateCharge(2F));
Console.WriteLine(“{0} charge = {1}”, earray[1].TypeName(),
earray[1].CalculateCharge(0.75F));
}
}
Lớp Engineer được thay đổi bằng cách thêm abstract vào trước lớp, mà nó thông báo rằng lớp là trừu tượng (ví dụ, có một hoặc nhiều hàm trừu tượng), và thêm abstract trước hàm ảo TypeName(). Sử dụng abstract cho hàm ảo là một điều quan trọng; từ abstract đứng trước tên của một lớp làm cho nó rõ ràng rằng lớp là trừu tượng, vì hàm trừu tượng có thể được che lấp một cách dễ dàng giữa các hàm khác.
Cài đặt CivilEngineer là như cũ, ngoại trừ việc bây giờ trình biên dịch sẽ kiểm tra cho chắc rằng TypeName() được cài đặt cho cả CivilEngineer và ChemicalEngineer.
Các lớp sealed
Các lớp sealed được sử dụng để ngăn ngừa một lớp sử dụng lớp đó như lớp cơ sở. Lợi ích chính là để ngăn ngừa các dẫn xuất không được dự tính trước.
// lỗi
sealed class MyClass {
MyClass() {
}
}
class MyNewClass : MyClass {
}
Lỗi này bởi vì MyNewClass không thể sử dụng MyClass như một lớp cơ sở bởi vì MyClass được niêm phong.