Làm thế nào để ngôn ngữ lập trình xác định chức năng?


28

Làm thế nào để ngôn ngữ lập trình định nghĩa và lưu các hàm / phương thức? Tôi đang tạo một ngôn ngữ lập trình được giải thích trong Ruby và tôi đang cố gắng tìm ra cách thực hiện khai báo hàm.

Ý tưởng đầu tiên của tôi là lưu nội dung khai báo trong bản đồ. Ví dụ, nếu tôi đã làm một cái gì đó như

def a() {
    callSomething();
    x += 5;
}

Sau đó, tôi sẽ thêm một mục vào bản đồ của mình:

{
    'a' => 'callSomething(); x += 5;'
}

Vấn đề với điều này là nó sẽ trở thành đệ quy, bởi vì tôi sẽ phải gọi parsephương thức của mình trên chuỗi, sau đó sẽ gọi parselại khi gặp phải doSomething, và cuối cùng tôi sẽ hết dung lượng ngăn xếp.

Vì vậy, làm thế nào để giải thích ngôn ngữ xử lý này?


Ồ, và đây là bài viết đầu tiên của tôi trên Lập trình viên. Vì vậy, vui lòng thông báo cho tôi nếu tôi làm gì sai hoặc đây là ngoài chủ đề. :)
Doorknob

Trước đây, tôi đã lưu trữ tất cả chúng trong dòng mã thông báo của mình và các lệnh gọi hàm chỉ là một bước nhảy đến một phần bù cụ thể (giống như các nhãn trong hội). Bạn đang token hóa kịch bản? Hoặc phân tích chuỗi mỗi lần?
Simon Whitehead

@SimonWhitehead Tôi chia chuỗi thành các mã thông báo và sau đó phân tách từng mã thông báo riêng biệt.
Doorknob

3
Nếu bạn chưa quen với việc thiết kế và triển khai ngôn ngữ lập trình, bạn có thể muốn kiểm tra một số tài liệu về chủ đề này. Cuốn sách phổ biến nhất là "Sách rồng": en.wikipedia.org/wiki/ , nhưng có những văn bản khác ngắn gọn hơn cũng rất hay. Ví dụ, triển khai ngôn ngữ lập trình của Aarne Ranta có thể được lấy miễn phí tại đây: bit.ly/15CF6gC .
evilcandybag

1
@ddyer Cảm ơn! Tôi đã tìm kiếm một thông dịch viên giỏi trong các ngôn ngữ khác nhau và điều đó thực sự có ích. :)
Doorknob

Câu trả lời:


31

Tôi có đúng không khi cho rằng hàm "phân tích cú pháp" của bạn không chỉ phân tích mã mà còn thực thi nó cùng một lúc? Nếu bạn muốn làm theo cách đó, thay vì lưu trữ nội dung của một chức năng trong bản đồ của bạn, hãy lưu trữ vị trí của chức năng.

Nhưng có một cách tốt hơn. Phải mất một chút nỗ lực trước mắt, nhưng nó mang lại kết quả tốt hơn nhiều khi độ phức tạp tăng lên: sử dụng Cây Cú pháp Trừu tượng.

Ý tưởng cơ bản là bạn chỉ phân tích mã một lần, bao giờ hết. Sau đó, bạn có một tập hợp các kiểu dữ liệu đại diện cho các hoạt động và giá trị và bạn tạo một cây trong số chúng, như vậy:

def a() {
    callSomething();
    x += 5;
}

trở thành:

Function Definition: [
   Name: a
   ParamList: []
   Code:[
      Call Operation: [
         Routine: callSomething
         ParamList: []
      ]
      Increment Operation: [
         Operand: x
         Value: 5
      ]
   ]
]

(Đây chỉ là một biểu diễn văn bản về cấu trúc của AST giả định. Cây thực tế có thể sẽ không ở dạng văn bản.) Dù sao, bạn phân tích mã của mình ra AST, và sau đó bạn trực tiếp chạy trình thông dịch của mình qua AST, hoặc sử dụng vượt qua thứ hai ("tạo mã") để biến AST thành một số dạng đầu ra.

Trong trường hợp ngôn ngữ của bạn, những gì bạn có thể sẽ làm là có một bản đồ ánh xạ tên hàm thành hàm AST, thay vì tên hàm thành chuỗi chức năng.


Được rồi, nhưng vấn đề vẫn còn đó: nó sử dụng đệ quy. Cuối cùng tôi sẽ hết dung lượng ngăn xếp nếu tôi làm điều này.
Doorknob

3
@Doorknob: Cụ thể sử dụng đệ quy nào? Bất kỳ ngôn ngữ lập trình khối cấu trúc (đó là tất cả các ngôn ngữ hiện đại ở một mức độ cao hơn so với ASM) vốn dĩ là cây dựa trên và do đó đệ quy trong tự nhiên. Những khía cạnh cụ thể nào bạn lo lắng về việc tràn ngăn xếp?
Mason Wheeler

1
@Doorknob: Vâng, đó là một thuộc tính vốn có của bất kỳ ngôn ngữ nào, ngay cả khi nó được biên dịch thành mã máy. (Ngăn xếp cuộc gọi là biểu hiện của hành vi này.) Tôi thực sự là người đóng góp cho hệ thống tập lệnh hoạt động theo cách tôi mô tả. Hãy cùng tôi trò chuyện tại chat.stackexchange.com/rooms/10470/ và tôi sẽ thảo luận về một số kỹ thuật để diễn giải hiệu quả và giảm thiểu tác động đến kích thước ngăn xếp với bạn. :)
Mason Wheeler

2
@Doorknob: Không có vấn đề đệ quy nào ở đây vì lệnh gọi hàm trong AST đang tham chiếu hàm theo tên , nó không cần tham chiếu đến hàm thực tế . Nếu bạn đang biên dịch mã máy thì cuối cùng bạn sẽ cần địa chỉ hàm, đó là lý do tại sao hầu hết các trình biên dịch thực hiện nhiều lần. Nếu bạn muốn có một trình biên dịch một lượt thì bạn cần "khai báo chuyển tiếp" tất cả các hàm để trình biên dịch có thể gán địa chỉ trước. Trình biên dịch mã byte thậm chí không bận tâm đến điều này, jitter xử lý tra cứu tên.
Aaronaught

5
@Doorknob: Nó thực sự đệ quy. Và có, nếu ngăn xếp của bạn chỉ có 16 mục, bạn sẽ không phân tích cú pháp (((((((((((((((( x ))))))))))))))))). Trong thực tế, các ngăn xếp có thể lớn hơn nhiều và độ phức tạp về ngữ pháp của mã thực là khá hạn chế. Chắc chắn nếu mã đó phải có thể đọc được.
MSalters

4

Bạn không nên gọi phân tích cú pháp khi nhìn thấy callSomething()(tôi đoán bạn có ý callSomethingchứ không phải doSomething). Sự khác biệt giữa acallSomethinglà một là một định nghĩa phương thức trong khi cái còn lại là một cuộc gọi phương thức.

Khi bạn thấy một định nghĩa mới, bạn sẽ muốn thực hiện kiểm tra liên quan để đảm bảo bạn có thể thêm định nghĩa đó, vì vậy:

  • Kiểm tra xem hàm không tồn tại với cùng chữ ký
  • Đảm bảo rằng khai báo phương thức đang được thực hiện trong phạm vi thích hợp (nghĩa là các phương thức có thể được khai báo bên trong các khai báo phương thức khác không?)

Giả sử các kiểm tra này vượt qua, bạn có thể thêm nó vào bản đồ của mình và bắt đầu kiểm tra nội dung của phương thức đó.

Khi bạn tìm thấy một cuộc gọi phương thức như thế nào callSomething(), bạn nên thực hiện các kiểm tra sau:

  • callSomethingtồn tại trong bản đồ của bạn?
  • Nó có được gọi đúng không (số lượng đối số khớp với chữ ký mà bạn đã tìm thấy)?
  • Các đối số có hợp lệ không (nếu tên biến được sử dụng, chúng có được khai báo không? Chúng có thể được truy cập ở phạm vi này không?)?
  • Có thể gọi một cái gì đó từ nơi bạn đang ở (nó là riêng tư, công cộng, được bảo vệ?)?

Nếu bạn thấy điều đó callSomething()là ổn, thì tại thời điểm này những gì bạn muốn làm thực sự phụ thuộc vào cách bạn muốn tiếp cận nó. Nói đúng ra, một khi bạn biết rằng một cuộc gọi như vậy là ổn vào thời điểm này, bạn chỉ có thể lưu tên của phương thức và các đối số mà không đi sâu vào chi tiết. Khi bạn chạy chương trình của mình, bạn sẽ gọi phương thức với các đối số bạn nên có trong thời gian chạy.

Nếu bạn muốn đi xa hơn, bạn có thể lưu không chỉ chuỗi mà còn liên kết đến phương thức thực tế. Điều này sẽ hiệu quả hơn, nhưng nếu bạn phải quản lý bộ nhớ, nó có thể gây nhầm lẫn. Tôi muốn khuyên bạn chỉ cần giữ chuỗi lúc đầu. Sau này bạn có thể cố gắng tối ưu hóa.

Lưu ý rằng đây là tất cả giả định rằng bạn đã lexx chương trình của bạn, có nghĩa là bạn đã nhận ra tất cả các mã thông báo trong chương trình của bạn và biết chúng gì . Điều đó không có nghĩa là bạn biết nếu chúng có ý nghĩa với nhau chưa, đó là giai đoạn phân tích cú pháp. Nếu bạn chưa biết mã thông báo là gì, tôi khuyên bạn trước tiên nên tập trung vào việc lấy thông tin đó trước.

Tôi hy vọng điều đó sẽ giúp! Chào mừng bạn đến với lập trình viên SE!


2

Đọc bài viết của bạn, tôi nhận thấy hai câu hỏi trong câu hỏi của bạn. Điều quan trọng nhất là làm thế nào để phân tích cú pháp. Có rất nhiều loại phân tích cú pháp (ví dụ Recursive gốc phân tích cú pháp , LR parsers , sưu tập điện phân tích cú pháp ) và máy phát phân tích cú pháp (ví dụ GNU bò rừng bizon , ANTLR ) bạn có thể sử dụng để đi qua một chương trình văn bản "đệ quy" được đưa ra một (rõ ràng hoặc ngầm) ngữ pháp.

Câu hỏi thứ hai là về định dạng lưu trữ cho các chức năng. Khi bạn không thực hiện dịch theo cú pháp , bạn tạo một biểu diễn trung gian của chương trình, có thể là cây cú pháp trừu tượng hoặc một số ngôn ngữ trung gian tùy chỉnh để xử lý thêm với nó (biên dịch, chuyển đổi, thực thi, viết trên một tập tin, v.v.)


1

Theo quan điểm chung, định nghĩa của hàm ít hơn một nhãn hoặc dấu trang trong mã. Hầu hết các vòng lặp, phạm vi và toán tử điều kiện khác là tương tự; chúng là các điểm độc lập cho lệnh "nhảy" hoặc "goto" cơ bản ở mức độ trừu tượng thấp hơn. Một cuộc gọi chức năng về cơ bản thực hiện theo các lệnh máy tính cấp thấp sau:

  • Ghép dữ liệu của tất cả các tham số, cộng với một con trỏ tới lệnh tiếp theo của hàm hiện tại, thành một cấu trúc được gọi là "khung ngăn xếp cuộc gọi".
  • Đẩy khung này vào ngăn xếp cuộc gọi.
  • Chuyển đến phần bù bộ nhớ của dòng đầu tiên của mã của hàm.

Một câu lệnh "return" hoặc tương tự sau đó sẽ thực hiện như sau:

  • Tải giá trị được trả lại vào một thanh ghi.
  • Tải con trỏ đến người gọi vào một thanh ghi.
  • Pop khung stack hiện tại.
  • Nhảy tới con trỏ của người gọi.

Các hàm, do đó, chỉ đơn giản là trừu tượng hóa trong một đặc tả ngôn ngữ cấp cao hơn, cho phép con người tổ chức mã theo cách dễ bảo trì và trực quan hơn. Khi được biên dịch thành một ngôn ngữ lắp ráp hoặc ngôn ngữ trung gian (JIL, MSIL, ILX) và chắc chắn khi được kết xuất dưới dạng mã máy, gần như tất cả các tóm tắt như vậy sẽ biến mất.

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.