Phương thức ảo (virtual và override)
Các lớp được thiết kế để mô tả các giao diện lập trình bên ngoài và cung cấp triển khai nội bộ của chúng. Vì chức năng của chương trình thử nghiệm của chúng ta là vẽ các hình dạng khác nhau, chúng ta đã mô tả một số biến trong lớp Shape
và các lớp dẫn xuất của nó để triển khai trong tương lai, đồng thời dự trữ phương thức draw
cho giao diện.
Trong lớp cơ sở Shape
, phương thức này không nên và không thể thực hiện bất kỳ điều gì vì Shape
không phải là một hình dạng cụ thể: sau này chúng ta sẽ chuyển đổi Shape
thành một lớp trừu tượng (chúng ta sẽ nói thêm về các lớp trừu tượng và giao diện sau).
Hãy định nghĩa lại phương thức draw
trong các lớp Rectangle
, Ellipse
và các lớp dẫn xuất khác (Shapes3.mq5
). Việc này bao gồm việc sao chép phương thức và sửa đổi nội dung của nó cho phù hợp. Mặc dù nhiều người gọi quá trình này là "ghi đè" (overriding), chúng ta sẽ phân biệt giữa hai thuật ngữ này, dành từ "ghi đè" chỉ cho các phương thức ảo, điều mà chúng ta sẽ thảo luận sau.
Nói một cách nghiêm ngặt, việc định nghĩa lại một phương thức chỉ yêu cầu tên phương thức phải trùng khớp. Tuy nhiên, để đảm bảo tính nhất quán trong toàn bộ mã, điều cần thiết là phải duy trì danh sách tham số và kiểu trả về giống nhau.
class Rectangle : public Shape
{
...
void draw()
{
Print("Drawing rectangle");
}
};
2
3
4
5
6
7
8
Vì chúng ta chưa biết cách vẽ trên màn hình, chúng ta sẽ chỉ xuất thông báo ra log.
Điều quan trọng cần lưu ý là bằng cách cung cấp một triển khai mới của phương thức trong lớp dẫn xuất, chúng ta sẽ có 2 phiên bản của phương thức: một phiên bản liên quan đến đối tượng cơ sở tích hợp bên trong (Shape
), và phiên bản còn lại liên quan đến lớp dẫn xuất bên ngoài (Rectangle
).
Phiên bản đầu tiên sẽ được gọi cho một biến kiểu Shape
, và phiên bản thứ hai cho một biến kiểu Rectangle
.
Trong một chuỗi kế thừa dài hơn, một phương thức có thể được định nghĩa lại và lan truyền nhiều lần hơn nữa.
Bạn có thể thay đổi kiểu truy cập của một phương thức mới, ví dụ, biến nó thành public
nếu trước đó nó là protected
, hoặc ngược lại. Nhưng trong trường hợp này, chúng ta đã để phương thức draw
trong phần public
.
Nếu cần, lập trình viên có thể gọi triển khai của phương thức từ bất kỳ lớp tổ tiên nào: để làm điều này, một toán tử phân giải ngữ cảnh đặc biệt được sử dụng – hai dấu hai chấm ::
. Cụ thể, chúng ta có thể gọi triển khai draw
từ lớp Rectangle
từ phương thức draw
của lớp Square
: để làm điều này, chúng ta chỉ định tên của lớp mong muốn, ::
và tên phương thức, ví dụ, Rectangle::draw()
. Việc gọi draw
mà không chỉ định ngữ cảnh ngụ ý phương thức của lớp hiện tại, và do đó nếu bạn thực hiện điều này từ chính phương thức draw
, bạn sẽ gặp phải một đệ quy vô hạn, và cuối cùng, gây tràn ngăn xếp và làm sập chương trình.
class Square : public Rectangle
{
public:
...
void draw()
{
Rectangle::draw();
Print("Drawing square");
}
};
2
3
4
5
6
7
8
9
10
Sau đó, việc gọi draw
trên đối tượng Square
sẽ ghi lại hai dòng vào log:
Square s(100, 200, 50, clrGreen);
s.draw(); // Drawing rectangle
// Drawing square
2
3
Việc gắn một phương thức với lớp mà nó được khai báo cung cấp cơ chế phân phối tĩnh (static dispatch hoặc static binding): trình biên dịch quyết định phương thức nào sẽ gọi ở giai đoạn biên dịch và "cố định" kết quả tìm được vào mã nhị phân.
Trong quá trình quyết định, trình biên dịch tìm kiếm phương thức được gọi trong đối tượng của lớp mà phép giải tham chiếu (.
) được thực hiện. Nếu phương thức có mặt, nó sẽ được gọi, nếu không, trình biên dịch kiểm tra lớp cha để tìm sự hiện diện của phương thức, và cứ tiếp tục như vậy, qua chuỗi kế thừa cho đến khi tìm thấy phương thức. Nếu phương thức không được tìm thấy trong bất kỳ lớp nào trong chuỗi, lỗi biên dịch "undeclared identifier" sẽ xảy ra.
Cụ thể, đoạn mã sau gọi phương thức setColor
trên đối tượng Rectangle
:
Rectangle r(100, 200, 75, 50, clrBlue);
r.setColor(clrWhite);
2
Tuy nhiên, phương thức này chỉ được định nghĩa trong lớp cơ sở Shape
và được tích hợp một lần trong tất cả các lớp dẫn xuất, do đó nó sẽ được thực thi ở đây.
Hãy thử bắt đầu vẽ các hình dạng bất kỳ từ một mảng trong hàm OnStart
(hãy nhớ rằng chúng ta đã sao chép và sửa đổi phương thức draw
trong tất cả các lớp dẫn xuất).
for(int i = 0; i < 10; ++i)
{
shapes[i].draw();
}
2
3
4
Kỳ lạ thay, không có gì được xuất ra log. Điều này xảy ra vì chương trình gọi phương thức draw
của lớp Shape
.
Có một nhược điểm lớn của phân phối tĩnh ở đây: khi chúng ta sử dụng một con trỏ đến lớp cơ sở để lưu trữ một đối tượng của lớp dẫn xuất, trình biên dịch chọn phương thức dựa trên kiểu của con trỏ, không phải đối tượng. Sự thật là ở giai đoạn biên dịch, chưa biết đối tượng lớp nào mà con trỏ sẽ trỏ đến trong quá trình thực thi chương trình.
Do đó, có nhu cầu về một cách tiếp cận linh hoạt hơn: phân phối động (dynamic dispatch hoặc dynamic binding), điều này sẽ hoãn việc chọn phương thức (từ tất cả các phiên bản được ghi đè của phương thức trong chuỗi dẫn xuất) đến thời điểm chạy. Việc lựa chọn phải được thực hiện dựa trên phân tích lớp thực tế của đối tượng tại con trỏ. Chính phân phối động cung cấp nguyên tắc của đa hình.
Cách tiếp cận này được triển khai trong MQL5 bằng các phương thức ảo. Trong mô tả của một phương thức như vậy, từ khóa virtual
phải được thêm vào đầu tiêu đề.
Hãy khai báo phương thức draw
trong lớp Shape
(Shapes4.mq5
) là ảo. Điều này sẽ tự động khiến tất cả các phiên bản của nó trong các lớp dẫn xuất cũng trở thành ảo.
class Shape
{
...
virtual void draw()
{
}
};
2
3
4
5
6
7
Khi một phương thức đã được ảo hóa, việc sửa đổi nó trong các lớp dẫn xuất được gọi là ghi đè (overriding) thay vì định nghĩa lại. Ghi đè yêu cầu tên, kiểu tham số và giá trị trả về của phương thức phải khớp (xem xét sự hiện diện/vắng mặt của các bộ sửa đổi const
).
Lưu ý rằng ghi đè các hàm ảo khác với nạp chồng hàm. Nạp chồng sử dụng cùng tên hàm, nhưng với các tham số khác nhau (cụ thể, chúng ta đã thấy khả năng nạp chồng một hàm tạo trong ví dụ về cấu trúc, xem Hàm tạo và Hàm hủy), còn ghi đè yêu cầu khớp hoàn toàn các chữ ký hàm.
Các hàm được ghi đè phải được định nghĩa trong các lớp khác nhau có quan hệ kế thừa. Các hàm nạp chồng phải ở trong cùng một lớp – nếu không, đó sẽ không phải là nạp chồng mà có thể là định nghĩa lại (và nó sẽ hoạt động khác, xem phân tích thêm về ví dụ
OverrideVsOverload.mq5
).
Nếu bạn chạy một script mới, các dòng dự kiến sẽ xuất hiện trong log, báo hiệu các cuộc gọi đến các phiên bản cụ thể của phương thức draw
trong mỗi lớp.
Drawing square
Drawing circle
Drawing triangle
Drawing ellipse
Drawing triangle
Drawing rectangle
Drawing square
Drawing triangle
Drawing square
Drawing triangle
2
3
4
5
6
7
8
9
10
Trong các lớp dẫn xuất nơi một phương thức ảo được ghi đè, nên thêm từ khóa override
vào tiêu đề của nó (mặc dù điều này không bắt buộc).
class Rectangle : public Shape
{
...
void draw() override
{
Print("Drawing rectangle");
}
};
2
3
4
5
6
7
8
Điều này cho phép trình biên dịch biết rằng chúng ta đang ghi đè phương thức một cách có chủ ý. Nếu trong tương lai API của lớp cơ sở đột nhiên thay đổi và phương thức được ghi đè không còn là ảo (hoặc bị xóa), trình biên dịch sẽ tạo ra thông báo lỗi: "method is declared with 'override' specifier, but does not override any base class method". Hãy nhớ rằng ngay cả việc thêm hoặc xóa bộ sửa đổi const
khỏi một phương thức cũng thay đổi chữ ký của nó, và việc ghi đè có thể bị phá vỡ vì điều này.
Từ khóa virtual
trước một phương thức được ghi đè cũng được phép nhưng không bắt buộc.
Để phân phối động hoạt động, trình biên dịch tạo ra một bảng các hàm ảo cho mỗi lớp. Một trường ẩn được thêm vào mỗi đối tượng với một liên kết đến bảng đã cho của lớp đó. Bảng này được trình biên dịch điền dựa trên thông tin về tất cả các phương thức ảo và các phiên bản được ghi đè của chúng dọc theo chuỗi kế thừa của một lớp cụ thể.
Một cuộc gọi đến một phương thức ảo được mã hóa trong hình ảnh nhị phân của chương trình theo cách đặc biệt: đầu tiên, bảng được tra cứu để tìm phiên bản cho lớp của một đối tượng cụ thể (nằm tại con trỏ), sau đó chuyển tiếp đến hàm phù hợp.
Kết quả là, phân phối động chậm hơn phân phối tĩnh.
Trong MQL5, các lớp luôn chứa một bảng các hàm ảo, bất kể có phương thức ảo hay không.
Nếu một phương thức ảo trả về một con trỏ đến một lớp, thì khi nó được ghi đè, có thể thay đổi (làm cho nó cụ thể hơn, chuyên biệt hơn) kiểu đối tượng của giá trị trả về. Nói cách khác, kiểu của con trỏ không chỉ có thể giống như trong khai báo ban đầu của phương thức ảo mà còn có thể là bất kỳ lớp kế thừa nào của nó. Các kiểu như vậy được gọi là "covariant" hoặc có thể hoán đổi.
Ví dụ, nếu chúng ta biến phương thức setColor
thành ảo trong lớp Shape
:
class Shape
{
...
virtual Shape *setColor(const color c)
{
backgroundColor = c;
return &this;
}
...
};
2
3
4
5
6
7
8
9
10
Chúng ta có thể ghi đè nó trong lớp Rectangle
như thế này (chỉ để minh họa công nghệ):
class Rectangle : public Shape
{
...
virtual Rectangle *setColor(const color c) override
{
// gọi phương thức gốc
// (bằng cách làm sáng màu trước,
// không quan trọng vì lý do gì)
Rectangle::setColor(c | 0x808080);
return &this;
}
};
2
3
4
5
6
7
8
9
10
11
12
Lưu ý rằng kiểu trả về là một con trỏ đến Rectangle
thay vì Shape
.
Điều này có ý nghĩa nếu phiên bản được ghi đè của phương thức thay đổi điều gì đó trong phần của đối tượng không thuộc về lớp cơ sở, để đối tượng thực sự không còn tương ứng với trạng thái cho phép (bất biến) của lớp cơ sở.
Ví dụ của chúng ta với việc vẽ hình dạng gần như đã sẵn sàng. Chỉ còn lại việc điền nội dung thực sự vào các phương thức ảo draw
. Chúng ta sẽ làm điều này trong chương Đồ họa (xem ví dụ ObjectShapesDraw.mq5
), nhưng chúng ta sẽ cải thiện nó sau khi nghiên cứu tài nguyên đồ họa.
Xét đến khái niệm kế thừa, quy trình mà trình biên dịch chọn phương thức phù hợp có vẻ hơi khó hiểu. Dựa trên tên phương thức và danh sách đối số cụ thể (kiểu của chúng) trong lệnh gọi, một danh sách tất cả các phương thức ứng cử viên có sẵn được biên soạn.
Đối với các phương thức không ảo, ban đầu chỉ các phương thức của lớp hiện tại được phân tích. Nếu không có phương thức nào khớp, trình biên dịch sẽ tiếp tục tìm kiếm trong lớp cơ sở (và sau đó là các tổ tiên xa hơn cho đến khi tìm thấy một kết quả phù hợp). Nếu trong số các phương thức của lớp hiện tại, có một phương thức phù hợp (ngay cả khi cần chuyển đổi ngầm định kiểu đối số), nó sẽ được chọn. Nếu lớp cơ sở có một phương thức với kiểu đối số phù hợp hơn (không cần chuyển đổi hoặc ít chuyển đổi hơn), trình biên dịch vẫn sẽ không đến được đó. Nói cách khác, các phương thức không ảo được phân tích bắt đầu từ lớp của đối tượng hiện tại hướng lên các tổ tiên đến kết quả "hoạt động" đầu tiên.
Đối với các phương thức ảo, trình biên dịch trước tiên tìm phương thức cần thiết theo tên trong lớp con trỏ và sau đó chọn triển khai trong bảng các hàm ảo cho lớp được khởi tạo nhất (dẫn xuất xa nhất) mà phương thức này được ghi đè trong chuỗi giữa kiểu con trỏ và kiểu đối tượng. Trong trường hợp này, chuyển đổi đối số ngầm định cũng có thể được sử dụng nếu không có sự khớp chính xác giữa các kiểu đối số.
Hãy xem xét ví dụ sau (OverrideVsOverload.mq5
). Có 4 lớp được liên kết với nhau: Base
, Derived
, Concrete
và Special
. Tất cả chúng chứa các phương thức với đối số kiểu int
và float
. Trong hàm OnStart
, các biến số nguyên i
và số thực f
được sử dụng làm đối số cho tất cả các cuộc gọi phương thức.
class Base
{
public:
void nonvirtual(float v)
{
Print(__FUNCSIG__, " ", v);
}
virtual void process(float v)
{
Print(__FUNCSIG__, " ", v);
}
};
class Derived : public Base
{
public:
void nonvirtual(int v)
{
Print(__FUNCSIG__, " ", v);
}
virtual void process(int v) // override
// error: 'Derived::process' method is declared with 'override' specifier,
// but does not override any base class method
{
Print(__FUNCSIG__, " ", v);
}
};
class Concrete : public Derived
{
};
class Special : public Concrete
{
public:
virtual void process(int v) override
{
Print(__FUNCSIG__, " ", v);
}
virtual void process(float v) override
{
Print(__FUNCSIG__, " ", v);
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
Đầu tiên, chúng ta tạo một đối tượng của lớp Concrete
và một con trỏ đến nó Base *ptr
. Sau đó, chúng ta gọi các phương thức không ảo và ảo cho chúng. Trong phần thứ hai, các phương thức của đối tượng Special
được gọi thông qua các con trỏ lớp Base
và Derived
.
void OnStart()
{
float f = 2.0;
int i = 1;
Concrete c;
Base *ptr = &c;
// Kiểm tra liên kết tĩnh
ptr.nonvirtual(i); // Base::nonvirtual(float), chuyển đổi int -> float
c.nonvirtual(i); // Derived::nonvirtual(int)
// cảnh báo: hành vi không được khuyến khích, gọi phương thức ẩn
c.nonvirtual(f); // Base::nonvirtual(float), vì
// việc chọn phương thức kết thúc ở Base,
// Derived::nonvirtual(int) không phù hợp với f
// Kiểm tra liên kết động
// chú ý: không có phương thức Base::process(int), cũng như
// không có ghi đè process(float) trong các lớp cho đến và bao gồm Concrete
ptr.process(i); // Base::process(float), chuyển đổi int -> float
c.process(i); // Derived::process(int), vì
// không có ghi đè trong Concrete,
// và ghi đè trong Special không được tính
Special s;
ptr = &s;
// chú ý: không có phương thức Base::process(int) trong ptr
ptr.process(i); // Special::process(float), chuyển đổi int -> float
ptr.process(f); // Special::process(float)
Derived *d = &s;
d.process(i); // Special::process(int)
// cảnh báo: hành vi không được khuyến khích, gọi phương thức ẩn
d.process(f); // Special::process(float)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
Đầu ra log được hiển thị dưới đây.
void Base::nonvirtual(float) 1.0
void Derived::nonvirtual(int) 1
void Base::nonvirtual(float) 2.0
void Base::process(float) 1.0
void Derived::process(int) 1
void Special::process(float) 1.0
void Special::process(float) 2.0
void Special::process(int) 1
void Special::process(float) 2.0
2
3
4
5
6
7
8
9
Cuộc gọi ptr.nonvirtual(i)
được thực hiện bằng cách sử dụng liên kết tĩnh, và số nguyên i
được ép kiểu trước sang kiểu tham số, float
.
Cuộc gọi c.nonvirtual(i)
cũng là tĩnh, và vì không có phương thức void nonvirtual(int)
trong lớp Concrete
, trình biên dịch tìm thấy phương thức như vậy trong lớp cha Derived
.
Việc gọi hàm cùng tên trên cùng đối tượng với giá trị kiểu float
dẫn trình biên dịch đến phương thức Base::nonvirtual(float)
vì Derived::nonvirtual(int)
không phù hợp (việc chuyển đổi sẽ dẫn đến mất độ chính xác). Trên đường đi, trình biên dịch đưa ra cảnh báo "deprecated behavior, hidden method calling".
Các phương thức nạp chồng có thể trông giống như được ghi đè (có cùng tên nhưng tham số khác nhau) nhưng chúng khác nhau vì chúng nằm ở các lớp khác nhau. Khi một phương thức trong lớp dẫn xuất ghi đè một phương thức trong lớp cha, nó thay thế hành vi của phương thức lớp cha, điều này đôi khi có thể gây ra hiệu ứng không mong muốn. Lập trình viên có thể mong đợi trình biên dịch chọn một phương thức phù hợp khác (như trong nạp chồng), nhưng thay vào đó, lớp con được gọi.
Để tránh các cảnh báo tiềm ẩn, nếu cần triển khai của lớp cha, nó nên được viết dưới dạng hàm hoàn toàn giống nhau trong lớp dẫn xuất, và lớp cơ sở nên được gọi từ đó.
class Derived : public Base
{
public:
...
// ghi đè này sẽ ngăn chặn cảnh báo
// "deprecated behavior, hidden method calling"
void nonvirtual(float v)
{
Base::nonvirtual(v);
Print(__FUNCSIG__, " ", v);
}
...
2
3
4
5
6
7
8
9
10
11
12
Hãy quay lại các bài kiểm tra trong OnStart
.
Cuộc gọi ptr.process(i)
thể hiện sự nhầm lẫn giữa nạp chồng và ghi đè đã mô tả ở trên. Lớp Base
có một phương thức ảo process(float)
, và lớp Derived
thêm một phương thức ảo mới process(int),
trong trường hợp này không phải là ghi đè vì các kiểu tham số khác nhau. Trình biên dịch chọn một phương thức theo tên trong lớp cơ sở và kiểm tra bảng hàm ảo để tìm các ghi đè trong chuỗi kế thừa đến lớp Concrete
(bao gồm, đây là lớp đối tượng theo con trỏ). Vì không tìm thấy ghi đè nào, trình biên dịch đã chọn Base::process(float)
và áp dụng chuyển đổi kiểu của đối số sang tham số (int
sang float
).
Nếu chúng ta tuân theo quy tắc luôn viết từ override
ở nơi ngụ ý định nghĩa lại và thêm nó vào Derived
, chúng ta sẽ nhận được lỗi:
class Derived : public Base
{
...
virtual void process(int v) override // lỗi!
{
Print(__FUNCSIG__, " ", v);
}
};
2
3
4
5
6
7
8
Trình biên dịch sẽ báo lỗi "'Derived::process' method is declared with 'override' specifier, but does not override any base class method". Điều này sẽ là gợi ý để sửa vấn đề.
Cuộc gọi process(i)
trên đối tượng Concrete
được thực hiện với Derived::process(int)
. Mặc dù chúng ta có một định nghĩa lại xa hơn trong lớp Special
, điều đó không liên quan vì nó được thực hiện trong chuỗi kế thừa sau lớp Concrete
.
Khi con trỏ ptr
sau đó được gán cho đối tượng Special
, các cuộc gọi đến process(i)
và process(f)
được trình biên dịch giải quyết thành Special::process(float)
vì Special
ghi đè Base::process(float)
. Việc chọn tham số float
xảy ra vì lý do tương tự như đã mô tả trước đó: phương thức Base::process(float)
được Special
ghi đè.
Nếu chúng ta áp dụng con trỏ d
kiểu Derived
, thì cuối cùng chúng ta nhận được cuộc gọi mong đợi Special::process(int)
cho chuỗi d.process(i)
. Điểm mấu chốt là process(int)
được định nghĩa trong Derived
, và nằm trong phạm vi tìm kiếm của trình biên dịch.
Lưu ý rằng lớp Special
không chỉ ghi đè các phương thức ảo kế thừa mà còn nạp chồng hai phương thức trong chính lớp đó.
WARNING
Không gọi một hàm ảo từ hàm tạo hoặc hàm hủy! Mặc dù về mặt kỹ thuật là có thể, hành vi ảo trong hàm tạo và hàm hủy hoàn toàn bị mất và bạn có thể nhận được kết quả không mong muốn. Không chỉ các cuộc gọi rõ ràng mà cả các cuộc gọi gián tiếp cũng nên được tránh (ví dụ, khi một phương thức đơn giản được gọi từ hàm tạo, mà phương thức đó lại gọi một hàm ảo).