Quy trình phổ biến được sử dụng khi trình biên dịch gõ tĩnh kiểm tra các biểu thức phức tạp trên đường truyền là gì?


23

Lưu ý: Khi tôi sử dụng "phức tạp" trong tiêu đề, tôi có nghĩa là biểu thức có nhiều toán tử và toán hạng. Không phải là bản thân biểu thức là phức tạp.


Gần đây tôi đã làm việc trên một trình biên dịch đơn giản để lắp ráp x86-64. Tôi đã hoàn thành giao diện chính của trình biên dịch - trình phân tích cú pháp và trình phân tích cú pháp - và giờ đây tôi có thể tạo đại diện Cây Cú pháp Tóm tắt cho chương trình của mình. Và vì ngôn ngữ của tôi sẽ được gõ tĩnh, bây giờ tôi đang thực hiện giai đoạn tiếp theo: gõ kiểm tra mã nguồn. Tuy nhiên, tôi đã gặp phải một vấn đề và không thể tự mình giải quyết nó một cách hợp lý.

Hãy xem xét ví dụ sau:

Trình phân tích cú pháp của trình biên dịch của tôi đã đọc dòng mã này:

int a = 1 + 2 - 3 * 4 - 5

Và chuyển đổi nó thành AST sau:

       =
     /   \
  a(int)  \
           -
         /   \
        -     5
      /   \
     +     *
    / \   / \
   1   2 3   4

Bây giờ nó phải gõ kiểm tra AST. nó bắt đầu bằng kiểu đầu tiên kiểm tra =toán tử. Đầu tiên nó kiểm tra phía bên trái của toán tử. Nó thấy rằng biến ađược khai báo là một số nguyên. Vì vậy, bây giờ nó phải xác minh rằng biểu thức phía bên phải ước tính thành một số nguyên.

Tôi hiểu làm thế nào điều này có thể được thực hiện nếu biểu thức chỉ là một giá trị duy nhất, chẳng hạn như 1hoặc 'a'. Nhưng làm thế nào điều này sẽ được thực hiện cho các biểu thức có nhiều giá trị và toán hạng - một biểu thức phức tạp - chẳng hạn như biểu thức ở trên? Để xác định chính xác giá trị của biểu thức, có vẻ như trình kiểm tra loại thực tế sẽ phải tự thực hiện biểu thức và ghi lại kết quả. Nhưng điều này rõ ràng dường như đánh bại mục đích tách các giai đoạn biên dịch và thực hiện.

Cách khác duy nhất tôi tưởng tượng điều này có thể được thực hiện là kiểm tra đệ quy lá của từng biểu hiện phụ trong AST và xác minh tất cả các loại lá phù hợp với loại toán tử dự kiến. Vì vậy, bắt đầu với =toán tử, trình kiểm tra kiểu sau đó sẽ quét tất cả các AST AST bên tay trái và xác minh rằng các lá đều là số nguyên. Sau đó, nó sẽ lặp lại điều này cho mỗi toán tử trong biểu thức con.

Tôi đã thử nghiên cứu chủ đề trong bản sao "Cuốn sách rồng" của mình , nhưng dường như nó không đi sâu vào chi tiết, và chỉ đơn giản nhắc lại những gì tôi đã biết.

Phương thức thông thường được sử dụng khi trình biên dịch là kiểu kiểm tra biểu thức với nhiều toán tử và toán hạng là gì? Có bất kỳ phương pháp tôi đã đề cập ở trên được sử dụng? Nếu không, các phương pháp là gì và chính xác chúng sẽ hoạt động như thế nào?


8
Có một cách rõ ràng và đơn giản để kiểm tra loại biểu thức. Bạn nên nói với chúng tôi điều gì khiến bạn gọi nó là "khó chịu".
gnasher729

12
Phương thức thông thường là "phương thức thứ hai": trình biên dịch xâm nhập vào loại biểu thức phức tạp từ các kiểu biểu thức con của nó. Đó là điểm chính của ngữ nghĩa học biểu thị, và hầu hết các hệ thống loại được tạo ra cho đến ngày nay.
Joker_vD

5
Hai cách tiếp cận có thể tạo ra hành vi khác nhau: Cách tiếp cận từ trên xuống double a = 7/2 sẽ cố gắng diễn giải phía bên phải là gấp đôi, do đó sẽ cố gắng giải thích tử số và mẫu số là gấp đôi và chuyển đổi chúng nếu cần; kết quả là a = 3.5. Từ dưới lên sẽ thực hiện phân chia số nguyên và chỉ chuyển đổi ở bước cuối cùng (gán), vì vậy a = 3.0.
Hagen von Eitzen

3
Lưu ý rằng hình ảnh AST của bạn không tương ứng với biểu hiện của bạn int a = 1 + 2 - 3 * 4 - 5mà làint a = 5 - ((4*3) - (1+2))
Basile Starynkevitch

22
Bạn có thể "thực thi" biểu thức trên các loại thay vì các giá trị; ví dụ int + inttrở thành int.

Câu trả lời:


14

Đệ quy là câu trả lời, nhưng bạn đi vào từng cây con trước khi xử lý thao tác:

int a = 1 + 2 - 3 * 4 - 5

để hình thành cây:

(assign (a) (sub (sub (add (1) (2)) (mul (3) (4))) (5))

Suy ra kiểu xảy ra bằng cách trước tiên đi bên trái, sau đó bên phải và sau đó xử lý toán tử ngay khi các kiểu toán hạng được suy ra:

(assign*(a) (sub (sub (add (1) (2)) (mul (3) (4))) (5))

-> xuống vào lhs

(assign (a*) (sub (sub (add (1) (2)) (mul (3) (4))) (5))

-> suy ra a. alà biết đến là int. assignBây giờ chúng ta quay lại nút:

(assign (int:a)*(sub (sub (add (1) (2)) (mul (3) (4))) (5))

-> đi xuống rhs, sau đó vào lhs của các toán tử bên trong cho đến khi chúng ta đạt được điều gì đó thú vị

(assign (int:a) (sub*(sub (add (1) (2)) (mul (3) (4))) (5))
(assign (int:a) (sub (sub*(add (1) (2)) (mul (3) (4))) (5))
(assign (int:a) (sub (sub (add*(1) (2)) (mul (3) (4))) (5))
(assign (int:a) (sub (sub (add (1*) (2)) (mul (3) (4))) (5))

-> suy luận loại 1, đó là int, và trở về với cha mẹ

(assign (int:a) (sub (sub (add (int:1)*(2)) (mul (3) (4))) (5))

-> đi vào rhs

(assign (int:a) (sub (sub (add (int:1) (2*)) (mul (3) (4))) (5))

-> suy luận loại 2, đó là int, và trở về với cha mẹ

(assign (int:a) (sub (sub (add (int:1) (int:2)*) (mul (3) (4))) (5))

-> suy luận loại add(int, int), đó là int, và trở về với cha mẹ

(assign (int:a) (sub (sub (int:add (int:1) (int:2))*(mul (3) (4))) (5))

-> đi xuống rhs

(assign (int:a) (sub (sub (int:add (int:1) (int:2)) (mul*(3) (4))) (5))

vv, cho đến khi bạn kết thúc với

(assign (int:a) (int:sub (int:sub (int:add (int:1) (int:2)) (int:mul (int:3) (int:4))) (int:5))*

Việc bản thân bài tập cũng là một biểu thức với một loại tùy thuộc vào ngôn ngữ của bạn.

Điểm quan trọng: để xác định loại của bất kỳ nút toán tử nào trong cây, bạn chỉ cần nhìn vào các con ngay lập tức của nó, cần phải có một loại được gán cho chúng.


43

Phương thức thường được sử dụng khi trình biên dịch là kiểu kiểm tra biểu thức với nhiều toán tử và toán hạng.

Đọc các wiki trên hệ thống loạisuy luận kiểu và trên hệ thống loại Hindley-Milner , sử dụng thống nhất . Đọc thêm về ngữ nghĩa họcngữ nghĩa hoạt động .

Kiểm tra loại có thể đơn giản hơn nếu:

  • tất cả các biến của bạn như ađược khai báo rõ ràng với một loại. Điều này giống như C hoặc Pascal hoặc C ++ 98, nhưng không giống như C ++ 11 có một số loại suy luận auto.
  • tất cả các giá trị theo nghĩa đen như 1, 2hoặc 'c'có một kiểu vốn có: một chữ int luôn có kiểu int, một ký tự chữ luôn có kiểu char, đánh.
  • các hàm và toán tử không bị quá tải, ví dụ +toán tử luôn có kiểu (int, int) -> int. C có quá tải cho các toán tử ( +hoạt động cho các kiểu số nguyên đã ký và không dấu và nhân đôi) nhưng không quá tải các hàm.

Trong các ràng buộc này, thuật toán trang trí kiểu đệ quy AST từ dưới lên có thể là đủ (điều này chỉ quan tâm đến các loại , không phải về các giá trị cụ thể, vì vậy là cách tiếp cận thời gian biên dịch):

  • Đối với mỗi phạm vi, bạn giữ một bảng cho các loại của tất cả các biến có thể nhìn thấy (được gọi là môi trường). Sau khi khai báo int a, bạn sẽ thêm mục a: intvào bảng.

  • Gõ lá là trường hợp cơ sở đệ quy tầm thường: loại chữ như 1đã biết và loại biến như acó thể được tra cứu trong môi trường.

  • Để nhập một biểu thức với một số toán tử và toán hạng theo các loại toán hạng (biểu thức con lồng nhau) được tính toán trước đó, chúng tôi sử dụng đệ quy trên các toán hạng (vì vậy chúng tôi nhập các biểu thức con này trước) và tuân theo các quy tắc gõ liên quan đến toán tử .

Vì vậy, trong ví dụ của bạn, 4 * 31 + 2được đánh máy int4& 31& 2đã gõ trước đó intvà các quy tắc gõ của bạn nói rằng tổng hoặc sản phẩm của hai int-s là một int, và vân vân cho (4 * 3) - (1 + 2).

Sau đó đọc các loại sách và ngôn ngữ lập trình của Pierce . Tôi khuyên bạn nên học một chút Ocaml-compus

Đối với các ngôn ngữ được nhập động hơn (giống như Lisp), hãy đọc cả Lisp của Queinnec trong các mảnh nhỏ

Đọc thêm cuốn sách Ngôn ngữ lập trình của Scott

BTW, bạn không thể có mã gõ bất khả tri ngôn ngữ, bởi vì hệ thống loại là một phần thiết yếu của ngữ nghĩa của ngôn ngữ .


2
Làm thế nào là C ++ 11 autokhông đơn giản? Nếu không có nó, bạn phải tìm ra loại ở phía bên phải, sau đó xem liệu có khớp hay chuyển đổi với loại ở phía bên trái không. Với autobạn chỉ cần tìm ra loại bên phải và bạn đã hoàn thành.
nwp

3
@nwp Ý tưởng chung về các định nghĩa biến C ++ auto, C # varvà Go :=rất đơn giản: gõ kiểm tra phía bên phải của định nghĩa. Loại kết quả là loại biến ở phía bên trái. Nhưng ma quỷ là trong các chi tiết. Ví dụ, các định nghĩa C ++ có thể tự tham chiếu để bạn có thể tham khảo biến được khai báo trên rhs, vd int i = f(&i). Nếu loại isuy ra, thuật toán trên sẽ thất bại: bạn cần biết loại iđể suy ra loại i. Thay vào đó, bạn cần suy luận kiểu kiểu đầy đủ với các biến kiểu.
amon

13

Trong C (và thẳng thắn nhất là các ngôn ngữ được gõ tĩnh dựa trên C), mọi toán tử có thể được xem là đường cú pháp cho một lệnh gọi hàm.

Vì vậy, biểu thức của bạn có thể được viết lại thành:

int a{operator-(operator-(operator+(1,2),operator*(3,4)),5)};

Sau đó, độ phân giải quá tải sẽ khởi động và quyết định rằng mọi chức năng là của (int, int)hoặc (const int&, const int&)loại.

Cách này làm cho độ phân giải loại dễ hiểu và làm theo và (quan trọng hơn) dễ thực hiện. Thông tin về các loại chỉ chảy theo 1 cách (từ các biểu thức bên trong ra bên ngoài).

Đó là lý do tại sao double x = 1/2;sẽ dẫn đến x == 01/2được đánh giá là một biểu thức int.


6
Hầu như đúng với C, nơi +không được xử lý như các lệnh gọi hàm (vì nó có cách gõ khác nhau cho doubleinttoán hạng)
Basile Starynkevitch

2
@BasileStarynkevitch: Đó là thực hiện như một loạt các chức năng quá tải: operator+(int,int), operator+(double,double), operator+(char*,size_t)vv Các phân tích cú pháp chỉ cần có để theo dõi cái nào được chọn.
Vịt Mooing

3
@aschepler Không ai đề xuất rằng ở cấp độ nguồn và thông số kỹ thuật, C thực sự có chức năng quá tải hoặc chức năng vận hành
mèo

1
Tất nhiên là không. Chỉ cần chỉ ra rằng trong trường hợp của trình phân tích cú pháp C, "lệnh gọi hàm" là một thứ khác mà bạn cần xử lý, nó thực sự không có nhiều điểm chung với "toán tử như các hàm gọi" như được mô tả ở đây. Trong thực tế, trong C tìm ra loại f(a,b)dễ hơn một chút so với tìm ra loại a+b.
aschepler

2
Bất kỳ trình biên dịch C hợp lý có nhiều giai đoạn. Gần phía trước (sau bộ tiền xử lý), bạn tìm thấy trình phân tích cú pháp, xây dựng AST. Ở đây khá rõ ràng rằng các nhà khai thác không gọi chức năng. Nhưng trong quá trình tạo mã, bạn không còn quan tâm cấu trúc ngôn ngữ nào đã tạo nút AST. Các thuộc tính của chính nút xác định cách xử lý nút. Cụ thể, + rất có thể là một lệnh gọi hàm - điều này thường xảy ra trên các nền tảng với toán học dấu phẩy động mô phỏng. Quyết định sử dụng toán FP mô phỏng xảy ra trong quá trình tạo mã; không có sự khác biệt AST trước đó cần thiết.
MSalters

6

Tập trung vào thuật toán của bạn, hãy thử thay đổi nó từ dưới lên. Bạn biết các biến pf và hằng số; gắn thẻ nút mang toán tử với loại kết quả. Hãy để chiếc lá xác định loại toán tử, cũng ngược lại với ý tưởng của bạn.


6

Nó thực sự khá dễ dàng, miễn là bạn nghĩ +là một loạt các chức năng hơn là một khái niệm duy nhất.

    int operator=(int)
     /   \
  a(int)  \
        int operator-(int,int)
         /                  \
    int operator-(int,int)    5
         /              \
int operator+(int,int) int operator*(int,int)
    / \                      / \
   1   2                    3   4

Trong giai đoạn phân tích cú pháp của phía bên tay phải, trình phân tích cú pháp truy xuất 1, biết rằng đó là một int, sau đó phân tích cú pháp +và lưu trữ dưới dạng "tên hàm chưa được giải quyết", sau đó phân tích cú pháp 2, biết đó là một int, và sau đó trả lại ngăn xếp đó. Nút +chức năng bây giờ biết cả hai loại tham số, vì vậy có thể phân giải +thành int operator+(int, int), vì vậy bây giờ nó biết loại biểu thức phụ này và trình phân tích cú pháp tiếp tục theo cách vui vẻ.

Như bạn có thể thấy, một khi cây được xây dựng đầy đủ, mỗi nút, bao gồm các lệnh gọi hàm, sẽ biết các kiểu của nó. Đây là chìa khóa vì nó cho phép các hàm trả về các loại khác nhau so với tham số của chúng.

char* ptr = itoa(3);

Ở đây, cây là:

    char* itoa(int)
     /           \
  ptr(char*)      3

4

Cơ sở để kiểm tra kiểu không phải là những gì trình biên dịch làm, nó là những gì ngôn ngữ định nghĩa.

Trong ngôn ngữ C, mỗi toán hạng có một loại. "abc" có loại "mảng const char". 1 có loại "int". 1L có loại "dài". Nếu x và y là biểu thức, thì có các quy tắc cho loại x + y, v.v. Vì vậy, trình biên dịch rõ ràng phải tuân theo các quy tắc của ngôn ngữ.

Trên các ngôn ngữ hiện đại như Swift, các quy tắc phức tạp hơn nhiều. Một số trường hợp đơn giản như trong C. Các trường hợp khác, trình biên dịch nhìn thấy một biểu thức, đã được thông báo trước về loại biểu thức nên có và xác định các loại biểu thức con dựa trên đó. Nếu x và y là các biến có kiểu khác nhau và một biểu thức giống hệt nhau được gán, biểu thức đó có thể được đánh giá theo một cách khác. Ví dụ: gán 12 * (2/3) sẽ gán 8.0 cho Double và 0 cho Int. Và bạn có trường hợp trình biên dịch biết rằng hai loại có liên quan và tìm ra loại nào dựa trên đó.

Ví dụ về Swift

var x: Double
var y: Int

x = 12 * (2 / 3)
y = 12 * (2 / 3)

print (x, y)

in "8.0, 0".

Trong bài tập x = 12 * (2/3): Phía bên trái có loại Double được biết đến, do đó phía bên phải phải có kiểu Double. Chỉ có một lần quá tải cho toán tử "*" trả về Double và đó là Double * Double -> Double. Do đó, 12 phải có loại Double, cũng như 2/3 hỗ trợ giao thức "IntegerLiteralConvertible". Double có một trình khởi tạo lấy một đối số kiểu "IntegerLiteralConvertible", do đó 12 được chuyển đổi thành Double. 2/3 phải có loại Double. Chỉ có một lần quá tải cho toán tử "/" trả về Double và đó là Double / Double -> Double. 2 và 3 được chuyển đổi thành Double. Kết quả của 2/3 là 0,6666666. Kết quả của 12 * (2/3) là 8,0. 8.0 được gán cho x.

Trong bài tập y = 12 * (2/3), y ở phía bên trái có kiểu Int, vì vậy phía bên phải phải có kiểu Int, do đó 12, 2, 3 được chuyển đổi thành Int với kết quả 2/3 = 0, 12 * (2/3) = 0.

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.