Cấu trúc visit
/ accept
cấu trúc của mẫu khách truy cập là một điều cần thiết do ngữ nghĩa của các ngôn ngữ giống C '(C #, Java, v.v.). Mục tiêu của mô hình khách truy cập là sử dụng điều phối kép để định tuyến cuộc gọi của bạn như bạn mong đợi khi đọc mã.
Thông thường khi mẫu khách truy cập được sử dụng, một hệ thống phân cấp đối tượng có liên quan trong đó tất cả các nút được bắt nguồn từ một Node
loại cơ sở , được gọi là từ đó trở đi Node
. Theo bản năng, chúng tôi viết nó như thế này:
Node root = GetTreeRoot();
new MyVisitor().visit(root);
Đây là vấn đề. Nếu MyVisitor
lớp của chúng ta được định nghĩa như sau:
class MyVisitor implements IVisitor {
void visit(CarNode node);
void visit(TrainNode node);
void visit(PlaneNode node);
void visit(Node node);
}
Nếu, trong thời gian chạy, bất kể kiểu thực tếroot
là gì, cuộc gọi của chúng ta sẽ chuyển sang trạng thái quá tải visit(Node node)
. Điều này sẽ đúng cho tất cả các biến được khai báo kiểu Node
. Tại sao thế này? Bởi vì Java và các ngôn ngữ giống C khác chỉ xem xét kiểu tĩnh , hoặc kiểu mà biến được khai báo, của tham số khi quyết định gọi quá tải nào. Java không thực hiện thêm bước nào để hỏi, đối với mỗi lần gọi phương thức, trong thời gian chạy, "Được rồi, kiểu động là root
gì? Ồ, tôi hiểu rồi. Đó là a TrainNode
. Hãy xem có phương thức MyVisitor
nào chấp nhận tham số kiểu khôngTrainNode
... ". Trình biên dịch, tại thời điểm biên dịch, xác định phương thức nào sẽ được gọi. (Nếu Java thực sự đã kiểm tra kiểu động của đối số, hiệu suất sẽ khá khủng khiếp.)
Java cung cấp cho chúng ta một công cụ để tính đến kiểu thời gian chạy (tức là động) của một đối tượng khi một phương thức được gọi - điều phối phương thức ảo . Khi chúng ta gọi một phương thức ảo, lệnh gọi thực sự đi đến một bảng trong bộ nhớ chứa các con trỏ hàm. Mỗi loại có một bảng. Nếu một phương thức cụ thể bị một lớp ghi đè, mục nhập bảng hàm của lớp đó sẽ chứa địa chỉ của hàm bị ghi đè. Nếu lớp không ghi đè một phương thức, nó sẽ chứa một con trỏ đến việc triển khai của lớp cơ sở. Điều này vẫn phát sinh chi phí hiệu suất (mỗi cuộc gọi phương thức về cơ bản sẽ tham chiếu đến hai con trỏ: một con trỏ đến bảng chức năng của kiểu và một con trỏ khác của chính hàm), nhưng nó vẫn nhanh hơn việc phải kiểm tra các loại tham số.
Mục tiêu của mẫu khách truy cập là thực hiện điều phối kép - không chỉ được xem xét loại mục tiêu cuộc gọi ( MyVisitor
, thông qua các phương thức ảo), mà còn là loại tham số (loại Node
chúng tôi đang xem xét)? Mẫu khách truy cập cho phép chúng tôi thực hiện điều này bằng cách kết hợp visit
/ accept
.
Bằng cách thay đổi dòng của chúng tôi thành:
root.accept(new MyVisitor());
Chúng tôi có thể nhận được những gì chúng tôi muốn: thông qua điều phối phương thức ảo, chúng tôi nhập chính xác lời gọi accept () như được triển khai bởi lớp con - trong ví dụ của chúng tôi với TrainElement
, chúng tôi sẽ nhập cách TrainElement
triển khai của accept()
:
class TrainNode extends Node implements IVisitable {
void accept(IVisitor v) {
v.visit(this);
}
}
Trình biên dịch biết gì tại thời điểm này, bên trong phạm vi của TrainNode
's accept
? Nó biết rằng kiểu tĩnh của this
là aTrainNode
. Đây là một mẩu thông tin bổ sung quan trọng mà trình biên dịch không nhận thức được trong phạm vi của trình gọi của chúng tôi: ở đó, tất cả những gì nó biết root
là nó là một Node
. Bây giờ trình biên dịch biết rằng this
( root
) không chỉ là a Node
, mà còn thực sự là a . Nếu cả hai đều không tồn tại, bạn sẽ gặp lỗi biên dịch (trừ khi bạn có quá tải xảy ra ). Do đó, quá trình thực thi sẽ nhập những gì chúng tôi đã dự định từ trước đến nay:TrainNode
. Do đó, một dòng được tìm thấy bên trong accept()
: v.visit(this)
, có nghĩa là một cái gì đó hoàn toàn khác. Trình biên dịch bây giờ sẽ tìm kiếm sự quá tải của visit()
nó mất a TrainNode
. Nếu nó không thể tìm thấy một cái, sau đó nó sẽ biên dịch cuộc gọi thành quá tải cóNode
object
MyVisitor
việc thực hiện visit(TrainNode e)
. Không cần phôi, và quan trọng nhất là không cần phản chiếu. Do đó, chi phí của cơ chế này khá thấp: nó chỉ bao gồm các tham chiếu con trỏ và không có gì khác.
Bạn nói đúng với câu hỏi của mình - chúng tôi có thể sử dụng dàn diễn viên và thực hiện hành vi chính xác. Tuy nhiên, thông thường, chúng ta thậm chí không biết loại Node là gì. Lấy trường hợp của hệ thống phân cấp sau:
abstract class Node { ... }
abstract class BinaryNode extends Node { Node left, right; }
abstract class AdditionNode extends BinaryNode { }
abstract class MultiplicationNode extends BinaryNode { }
abstract class LiteralNode { int value; }
Và chúng tôi đang viết một trình biên dịch đơn giản để phân tích một tệp nguồn và tạo ra một cấu trúc phân cấp đối tượng phù hợp với đặc điểm kỹ thuật ở trên. Nếu chúng tôi đang viết thông dịch viên cho hệ thống phân cấp được triển khai với tư cách là Khách truy cập:
class Interpreter implements IVisitor<int> {
int visit(AdditionNode n) {
int left = n.left.accept(this);
int right = n.right.accept(this);
return left + right;
}
int visit(MultiplicationNode n) {
int left = n.left.accept(this);
int right = n.right.accept(this);
return left * right;
}
int visit(LiteralNode n) {
return n.value;
}
}
Việc truyền sẽ không giúp chúng ta đi xa được, vì chúng ta không biết các loại left
hoặc right
trong các visit()
phương pháp. Trình phân tích cú pháp của chúng tôi rất có thể cũng sẽ chỉ trả về một đối tượng kiểu Node
trỏ vào gốc của hệ thống phân cấp, vì vậy chúng tôi cũng không thể ép kiểu đó một cách an toàn. Vì vậy, trình thông dịch đơn giản của chúng tôi có thể trông giống như:
Node program = parse(args[0]);
int result = program.accept(new Interpreter());
System.out.println("Output: " + result);
Mẫu khách truy cập cho phép chúng ta làm một điều gì đó rất mạnh mẽ: với một hệ thống phân cấp đối tượng, nó cho phép chúng ta tạo các hoạt động mô-đun hoạt động trên hệ thống phân cấp mà không cần phải đặt mã vào chính lớp của hệ thống phân cấp. Ví dụ: mẫu khách truy cập được sử dụng rộng rãi trong xây dựng trình biên dịch. Với cây cú pháp của một chương trình cụ thể, nhiều khách truy cập được viết hoạt động trên cây đó: kiểm tra kiểu, tối ưu hóa, phát mã máy, tất cả thường được thực hiện như những khách truy cập khác nhau. Trong trường hợp của khách truy cập tối ưu hóa, nó thậm chí có thể xuất ra một cây cú pháp mới với cây đầu vào.
Tất nhiên, nó có những hạn chế: nếu chúng ta thêm một kiểu mới vào hệ thống phân cấp, chúng ta cũng cần thêm một visit()
phương thức cho kiểu mới đó vào IVisitor
giao diện và tạo các triển khai sơ khai (hoặc đầy đủ) trong tất cả khách truy cập của chúng ta. Chúng tôi cũng cần thêm accept()
phương thức vì những lý do được mô tả ở trên. Nếu hiệu suất không có nhiều ý nghĩa đối với bạn, có những giải pháp để viết cho khách truy cập mà không cần đến accept()
, nhưng chúng thường liên quan đến sự phản ánh và do đó có thể phát sinh chi phí khá lớn.