Lập trình chức năng là gì. Một cách tiếp cận để tính toán các lập luận. Cú pháp trong hai phút

Lập trình hàm kết hợp các cách tiếp cận khác nhau để xác định các quy trình tính toán dựa trên các khái niệm trừu tượng khá chặt chẽ và các phương pháp xử lý dữ liệu tượng trưng.

Một đặc điểm của ngôn ngữ lập trình hàm là văn bản chương trình trong ngôn ngữ lập trình hàm mô tả “cách giải quyết vấn đề”, nhưng không quy định trình tự hành động để giải quyết vấn đề đó. Các đặc tính chính của ngôn ngữ lập trình chức năng: ngắn gọn, đơn giản, gõ mạnh, tính mô đun, sự hiện diện của các phép tính lười biếng (lười biếng).

Các ngôn ngữ lập trình chức năng bao gồm: Lisp, Miranda, Gofel, ML, Standard ML, Objective CAML, F#, Scala, Pythagoras, v.v.

Ngôn ngữ lập trình thủ tục

Ngôn ngữ lập trình thủ tục cho phép người lập trình xác định từng bước trong quá trình giải quyết vấn đề. Điểm đặc biệt của các ngôn ngữ lập trình như vậy là các nhiệm vụ được chia thành các bước và giải quyết từng bước. Sử dụng ngôn ngữ thủ tục, lập trình viên xác định các cấu trúc ngôn ngữ để thực hiện một chuỗi các bước thuật toán.

Ngôn ngữ lập trình thủ tục: Ada, Basic, C, COBOL, Pascal, PL/1, Rapier, v.v.

Ngôn ngữ lập trình ngăn xếp

Ngôn ngữ lập trình ngăn xếp là ngôn ngữ lập trình sử dụng mô hình máy của ngăn xếp để truyền tham số. Các ngôn ngữ lập trình dựa trên ngăn xếp: Forth, PostScript, Java, C#, v.v. Khi sử dụng ngăn xếp làm kênh chính để truyền tham số giữa các từ, các thành phần ngôn ngữ sẽ tạo thành các cụm từ một cách tự nhiên (chuỗi tuần tự). Thuộc tính này đưa các ngôn ngữ này đến gần hơn với ngôn ngữ tự nhiên.

Ngôn ngữ lập trình hướng theo khía cạnh 5) Ngôn ngữ lập trình khai báo 6) Ngôn ngữ lập trình động 7) Ngôn ngữ giáo dục lập trình 8) Ngôn ngữ mô tả giao diện 9) Ngôn ngữ lập trình nguyên mẫu 10) Ngôn ngữ lập trình hướng đối tượng 11) Ngôn ngữ lập trình logic 12) Ngôn ngữ lập trình kịch bản 13) Ngôn ngữ lập trình bí truyền


Tiêu chuẩn hóa ngôn ngữ lập trình. Mô hình lập trình

Khái niệm về ngôn ngữ lập trình gắn bó chặt chẽ với việc triển khai nó. Để đảm bảo rằng việc biên dịch cùng một chương trình bằng các trình biên dịch khác nhau luôn cho kết quả như nhau, các tiêu chuẩn ngôn ngữ lập trình đang được phát triển. Các tổ chức tiêu chuẩn hóa: Viện Tiêu chuẩn Quốc gia Hoa Kỳ ANSI, Viện Kỹ sư Điện và Điện tử IEEE, Tổ chức Tiêu chuẩn Quốc tế ISO.



Khi một ngôn ngữ được tạo ra, một tiêu chuẩn riêng sẽ được phát hành, do các nhà phát triển ngôn ngữ xác định. Nếu một ngôn ngữ trở nên phổ biến thì theo thời gian phiên bản khác nhau các trình biên dịch không tuân theo chính xác một tiêu chuẩn riêng. Trong hầu hết các trường hợp, có sự mở rộng các khả năng cố định ban đầu của ngôn ngữ. Một tiêu chuẩn đồng thuận đang được phát triển để đưa các triển khai ngôn ngữ phổ biến nhất phù hợp với nhau. Rất yếu tố quan trọng Việc tiêu chuẩn hóa ngôn ngữ lập trình là tính kịp thời của sự xuất hiện của tiêu chuẩn - trước khi ngôn ngữ được phân phối rộng rãi và tạo ra nhiều cách triển khai không tương thích. Trong quá trình phát triển ngôn ngữ, những tiêu chuẩn mới có thể xuất hiện, phản ánh những đổi mới hiện đại.

Mô hình lập trình

Mô hình- một tập hợp các lý thuyết, tiêu chuẩn và phương pháp cùng thể hiện một cách tổ chức kiến ​​thức khoa học - nói cách khác là một cách nhìn thế giới. Bằng cách tương tự, người ta thường chấp nhận rằng mô hình trong lập trình là một cách khái niệm hóa xác định cách thực hiện các phép tính và cách cấu trúc và tổ chức công việc được thực hiện bởi máy tính.

Có một số mô hình lập trình cơ bản, trong đó quan trọng nhất là khoảnh khắc này time là mô hình của lập trình chỉ thị, hướng đối tượng và logic chức năng. Để hỗ trợ lập trình theo một mô hình cụ thể, các ngôn ngữ thuật toán đặc biệt đã được phát triển.

C và Pascal là ví dụ về các ngôn ngữ được thiết kế cho lập trình quy định, trong đó nhà phát triển chương trình sử dụng mô hình hướng quy trình, nghĩa là cố gắng tạo mã hoạt động trên dữ liệu theo cách thích hợp. Trong cách tiếp cận này, nguyên tắc hoạt động được coi là một chương trình (mã), phải thực hiện tất cả các hành động cần thiết trên dữ liệu thụ động để đạt được kết quả mong muốn.


Công nghệ lập trình là một quá trình phát triển các sản phẩm phần mềm được tạo ra như một tổng thể không thể tách rời dưới dạng các chương trình và phần mềm đã được thử nghiệm kỹ lưỡng. tài liệu giảng dạy, mô tả mục đích và cách sử dụng của chúng.

Lập trình- quá trình sáng tạo chương trình máy tính. Trong hơn theo nghĩa rộng: phạm vi các hoạt động liên quan đến việc tạo ra và duy trì công việc. trạng thái của chương trình - phần mềm máy tính.

Công nghệ lập trình- một tập hợp các phương pháp và công cụ được sử dụng trong quá trình phát triển phần mềm.

Công nghệ lập trình là tập hợp các chỉ dẫn công nghệ, bao gồm:

· chỉ dẫn trình tự các hoạt động công nghệ;

· liệt kê các điều kiện theo đó hoạt động này hoặc hoạt động kia được thực hiện;

· mô tả về các hoạt động, trong đó dữ liệu, kết quả ban đầu, cũng như các hướng dẫn, quy định, tiêu chuẩn, tiêu chí, v.v. được xác định cho mỗi hoạt động.

Công nghệ lập trình hiện đại - cách tiếp cận thành phần, bao gồm việc xây dựng phần mềm từ các thành phần riêng lẻ - các phần mềm riêng biệt về mặt vật lý tương tác với nhau thông qua giao diện nhị phân được tiêu chuẩn hóa. Hiện nay Tiêu chuẩn chất lượng một sản phẩm phần mềm được coi là:- chức năng; − độ tin cậy;− dễ sử dụng;− hiệu quả(tỷ lệ giữa mức độ dịch vụ do sản phẩm phần mềm cung cấp cho người dùng trong các điều kiện nhất định trên khối lượng tài nguyên được sử dụng); − khả năng bảo trì(các đặc điểm của sản phẩm phần mềm cho phép giảm thiểu nỗ lực thực hiện các thay đổi nhằm loại bỏ lỗi trong sản phẩm đó và sửa đổi sản phẩm đó cho phù hợp với nhu cầu thay đổi của người dùng); tính di động(khả năng một hệ thống phần mềm được chuyển từ môi trường này sang môi trường khác, đặc biệt là từ máy tính này sang máy tính khác).

Một bước quan trọng tạo ra một sản phẩm phần mềm. thử nghiệm và gỡ lỗi.

Gỡ lỗi− đây là hoạt động nhằm phát hiện và sửa lỗi trong sản phẩm phần mềm bằng cách sử dụng quy trình thực thi chương trình của nó.

Kiểm tra− đây là quá trình thực thi các chương trình của nó trên một tập hợp dữ liệu nhất định mà kết quả của ứng dụng đã được biết trước hoặc các quy tắc hoạt động của các chương trình này đã được biết trước.

Hiện có các phương pháp thử nghiệm PS sau đây:

1) Kiểm tra tĩnh - kiểm tra thủ công chương trình tại bàn.

2) Thử nghiệm xác định - với nhiều sự kết hợp khác nhau của dữ liệu nguồn.

3) Ngẫu nhiên – ref. Dữ liệu được chọn ngẫu nhiên và đầu ra được xác định bởi chất lượng của kết quả hoặc ước tính gần đúng.


Các phong cách lập trình.

Phong cách lập trình là tập hợp các kỹ thuật hoặc phương pháp lập trình mà người lập trình sử dụng để tạo ra các chương trình chính xác, hiệu quả, dễ sử dụng và dễ đọc.

Có một số phong cách lập trình:

  1. Lập trình thủ tục là lập trình trong đó chương trình là một chuỗi các câu lệnh. Được sử dụng trong các ngôn ngữ cấp cao Basic, Fortran, v.v.
  2. Lập trình chức năng là lập trình trong đó chương trình là một chuỗi các lệnh gọi hàm. Được sử dụng trong Lisp và các ngôn ngữ khác.
  3. Lập trình logic – đây là chương trình trong đó chương trình là một tập hợp các xác định mối quan hệ giữa các đối tượng. Được sử dụng trong Prolog và các ngôn ngữ khác.

Lập trình hướng đối tượng– đây là chương trình trong đó cơ sở của chương trình là một đối tượng là tập hợp dữ liệu và quy tắc để chuyển đổi chúng. Được dùng trong Ngôn ngữ Turbo-Pascal, C++, v.v.

Lập trình hàm là một nhánh của toán học rời rạc và một mô hình lập trình trong đó quá trình tính toán được hiểu là tính toán các giá trị của hàm theo nghĩa toán học sau này (trái ngược với các hàm như chương trình con trong lập trình thủ tục).

Nó trái ngược với mô hình lập trình mệnh lệnh, mô hình này mô tả quá trình tính toán như một sự thay đổi tuần tự của các trạng thái (theo nghĩa tương tự như trong lý thuyết automata). Nếu cần thiết, trong lập trình hàm, toàn bộ tập hợp các trạng thái tuần tự của quá trình tính toán được biểu diễn rõ ràng, chẳng hạn như dưới dạng danh sách.

Lập trình hàm bao gồm việc tính toán kết quả của các hàm từ dữ liệu đầu vào và kết quả của các hàm khác và không liên quan đến việc lưu trữ rõ ràng trạng thái chương trình. Theo đó, nó không ngụ ý khả năng thay đổi của trạng thái này (không giống như trạng thái bắt buộc, trong đó một trong những khái niệm cơ bản là một biến lưu trữ giá trị của nó và cho phép nó được thay đổi khi thuật toán thực thi).

Trong thực tế, sự khác biệt giữa hàm toán học và khái niệm “hàm” trong lập trình mệnh lệnh là hàm mệnh lệnh không chỉ có thể dựa vào đối số mà còn dựa vào trạng thái của các biến bên ngoài hàm, đồng thời cũng có tác dụng phụ và thay đổi tính chất của hàm. trạng thái của các biến bên ngoài. Như vậy, trong lập trình mệnh lệnh, khi gọi cùng một hàm với cùng tham số nhưng ở các giai đoạn khác nhau của thuật toán, bạn có thể nhận được dữ liệu đầu ra khác nhau do ảnh hưởng của trạng thái của các biến đến hàm. Và trong ngôn ngữ hàm, khi gọi một hàm có cùng đối số, chúng ta luôn nhận được kết quả giống nhau: đầu ra chỉ phụ thuộc vào đầu vào. Điều này cho phép các thời gian chạy ngôn ngữ chức năng lưu vào bộ đệm kết quả của các hàm và gọi chúng theo thứ tự không được xác định bởi thuật toán. (xem Hàm thuần túy bên dưới)



λ-tính toán là cơ sở cho lập trình hàm, nhiều ngôn ngữ hàm có thể được coi là một “cấu trúc thượng tầng” bên trên nó.

Điểm mạnh

[sửa] Tăng độ tin cậy của mã

Mặt hấp dẫn của điện toán không trạng thái là độ tin cậy của mã tăng lên nhờ cấu trúc rõ ràng và không cần theo dõi các tác dụng phụ. Bất kỳ chức năng nào chỉ hoạt động với dữ liệu cục bộ và luôn hoạt động với nó theo cùng một cách, bất kể nó được gọi ở đâu, như thế nào và trong hoàn cảnh nào. Không có khả năng thay đổi dữ liệu khi sử dụng nó trong Những nơi khác nhau x của chương trình giúp loại bỏ khả năng xảy ra các lỗi khó phát hiện (chẳng hạn như vô tình gán sai giá trị cho một biến toàn cục trong một chương trình bắt buộc).

[sửa]Dễ dàng tổ chức thử nghiệm đơn vị

Vì một hàm trong lập trình hàm không thể tạo ra tác dụng phụ nên các đối tượng không thể thay đổi bên trong hoặc bên ngoài phạm vi (không giống như các chương trình mệnh lệnh, trong đó một hàm có thể đặt một số biến bên ngoài được hàm thứ hai đọc). Tác dụng duy nhất của việc đánh giá một hàm là kết quả mà nó trả về và yếu tố duy nhất ảnh hưởng đến kết quả là giá trị của các đối số.

Vì vậy, có thể kiểm tra từng hàm trong một chương trình bằng cách đánh giá nó từ các bộ giá trị đối số khác nhau. Trong trường hợp này, bạn không phải lo lắng về việc gọi các hàm trong yêu cầu hợp lý, cũng không phải về hình thành đúng trạng thái bên ngoài. Nếu bất kỳ chức năng nào trong chương trình vượt qua các bài kiểm tra đơn vị thì bạn có thể tin tưởng vào chất lượng của toàn bộ chương trình. Trong các chương trình mệnh lệnh, việc kiểm tra giá trị trả về của hàm là chưa đủ: hàm có thể sửa đổi trạng thái bên ngoài, điều này cũng cần được kiểm tra, điều này không cần thực hiện trong các chương trình chức năng.

[sửa] Tùy chọn tối ưu hóa trong quá trình biên dịch

Một tính năng tích cực được đề cập theo truyền thống của lập trình hàm là nó cho phép bạn mô tả một chương trình ở dạng được gọi là “khai báo”, khi trình tự cứng nhắc thực hiện nhiều thao tác cần thiết để tính kết quả không được chỉ định rõ ràng mà được hình thành tự động trong quá trình tính toán các hàm [nguồn] không được chỉ định 1064 ngày] Hoàn cảnh này, cũng như sự vắng mặt của các trạng thái, khiến cho việc áp dụng các phương pháp tối ưu hóa tự động khá phức tạp vào các chương trình chức năng có thể thực hiện được.

[sửa]Khả năng đồng thời

Một ưu điểm khác của các chương trình chức năng là chúng cung cấp cơ hội rộng lớn nhấtđể tự động song song hóa các phép tính. Vì đảm bảo không có tác dụng phụ nên trong bất kỳ lệnh gọi hàm nào, luôn có thể đánh giá song song hai tham số khác nhau - thứ tự chúng được đánh giá không thể ảnh hưởng đến kết quả của lệnh gọi.

[sửa] Nhược điểm

Những nhược điểm của lập trình hàm bắt nguồn từ những tính năng giống nhau. Việc không có nhiệm vụ và thay thế chúng bằng việc tạo dữ liệu mới dẫn đến nhu cầu phân bổ liên tục và giải phóng bộ nhớ tự động, do đó, trong hệ thống thực thi của chương trình chức năng, bộ thu gom rác hiệu quả cao trở thành một thành phần bắt buộc. Mô hình tính toán lỏng lẻo dẫn đến thứ tự gọi hàm không thể đoán trước, điều này tạo ra các vấn đề trong I/O trong đó thứ tự thao tác là quan trọng. Ngoài ra, rõ ràng, các hàm đầu vào ở dạng tự nhiên (chẳng hạn như getchar từ thư viện chuẩn C) không rõ ràng vì chúng có thể trả về các giá trị khác nhau cho cùng một đối số và điều này đòi hỏi một số thủ thuật để khắc phục.

Để khắc phục những thiếu sót của các chương trình chức năng, các ngôn ngữ lập trình chức năng đầu tiên không chỉ bao gồm các công cụ chức năng thuần túy mà còn bao gồm các cơ chế lập trình bắt buộc (gán, vòng lặp, “PROGN ẩn” đã có trong LISP). Sử dụng các công cụ như vậy cho phép bạn giải quyết một số vấn đề thực tế, nhưng đồng nghĩa với việc bạn phải rời xa các ý tưởng (và ưu điểm) của lập trình hàm và viết các chương trình mệnh lệnh bằng ngôn ngữ hàm. các phương tiện khác, ví dụ, trong ngôn ngữ Haskell I/O được triển khai bằng cách sử dụng các đơn nguyên, một khái niệm không hề tầm thường được mượn từ lý thuyết phạm trù.

Đệ quy đuôi là trường hợp đặc biệt của đệ quy trong đó lệnh gọi đệ quy của hàm đến chính nó là thao tác cuối cùng. Kiểu đệ quy này đáng chú ý ở chỗ nó có thể dễ dàng được thay thế bằng phép lặp, được triển khai trong nhiều trình biên dịch tối ưu hóa. Khi một hàm được gọi, máy tính phải nhớ vị trí mà hàm được gọi (địa chỉ trả về) để quay lại sau khi hoàn thành và tiếp tục thực hiện chương trình. Thông thường địa chỉ trả về được lưu trữ trên ngăn xếp. Đôi khi hành động cuối cùng của một hàm sau khi tất cả các thao tác khác đã hoàn thành chỉ đơn giản là gọi hàm đó, có thể là gọi chính hàm đó và trả về một kết quả. Trong trường hợp này, không cần phải nhớ địa chỉ trả về, hàm mới được gọi sẽ trả kết quả trực tiếp về nơi hàm ban đầu được gọi. Đệ quy đuôi thường được sử dụng trong các chương trình bằng ngôn ngữ lập trình hàm. Việc thể hiện nhiều phép tính bằng các ngôn ngữ như vậy dưới dạng hàm đệ quy là điều tự nhiên và khả năng người dịch tự động thay thế đệ quy đuôi bằng phép lặp có nghĩa là về mặt hiệu quả tính toán, nó tương đương với mã tương đương được viết ở dạng lặp.

Những người tạo ra Lược đồ ngôn ngữ chức năng, một trong những phương ngữ của Lisp, đánh giá cao tầm quan trọng của đệ quy đuôi đến mức trong đặc tả ngôn ngữ, họ đã quy định mỗi người dịch ngôn ngữ này phải bắt buộc thực hiện tối ưu hóa đệ quy đuôi.

Hàm bậc cao hơn- một hàm nhận các hàm khác làm đối số hoặc kết quả trả về một hàm khác. Đôi khi chức năng thứ tự cao hơnđược gọi là hàm, mặc dù điều này không hoàn toàn đúng; một thuật ngữ tương đương chính xác hơn là toán tử.

Trong các ngôn ngữ lập trình hàm, tất cả các hàm đều là hàm bậc cao hơn.

Đảo ngược chuỗi(String arg) ( if(arg.length == 0) ( return arg; ) else ( return Reverse(arg.substring(1, arg.length)) + arg.substring(0, 1); ) )
Chức năng này khá chậm vì nó gọi chính nó nhiều lần. Có thể có rò rỉ bộ nhớ ở đây vì các đối tượng tạm thời được tạo nhiều lần. Nhưng đây là một phong cách chức năng. Bạn có thể thấy lạ khi mọi người có thể lập trình như thế này. À, tôi vừa định nói với bạn.

Lợi ích của lập trình chức năng

Có lẽ bạn đang nghĩ rằng tôi không thể đưa ra lý do nào để biện minh cho đặc điểm quái dị ở trên. Khi mới bắt đầu học lập trình hàm, tôi cũng nghĩ như vậy. Tôi đã sai. Có những lập luận rất tốt cho phong cách này. Một số trong số đó là chủ quan. Ví dụ, các lập trình viên cho rằng các chương trình chức năng dễ hiểu hơn. Tôi sẽ không đưa ra những lập luận như vậy, bởi ai cũng biết rằng dễ hiểu là một điều rất chủ quan. May mắn cho tôi là vẫn còn rất nhiều lý lẽ khách quan.

Kiểm tra đơn vị

Vì mỗi ký hiệu trong FP là bất biến nên các hàm không có tác dụng phụ. Bạn không thể thay đổi giá trị của biến và một hàm không thể thay đổi giá trị ngoài phạm vi của nó và do đó ảnh hưởng đến các hàm khác (điều có thể xảy ra với các trường lớp hoặc biến toàn cục). Điều này có nghĩa là kết quả duy nhất của việc thực thi hàm là giá trị trả về. Và điều duy nhất có thể ảnh hưởng đến giá trị trả về là các đối số được truyền cho hàm.

Đây rồi, giấc mơ xanh của những người thử nghiệm đơn vị. Bạn có thể kiểm tra mọi chức năng trong một chương trình chỉ bằng cách sử dụng các đối số được yêu cầu. Không cần phải gọi các hàm theo đúng thứ tự hoặc tạo lại trạng thái bên ngoài chính xác. Tất cả những gì bạn cần làm là truyền các đối số khớp với các trường hợp đặc biệt. Nếu tất cả các chức năng trong chương trình của bạn vượt qua các bài kiểm tra đơn vị, thì bạn có thể tự tin hơn nhiều về chất lượng phần mềm của mình so với trường hợp ngôn ngữ lập trình bắt buộc. Trong Java hoặc C++, việc kiểm tra giá trị trả về là chưa đủ - hàm có thể thay đổi trạng thái bên ngoài, điều này cũng phải được kiểm tra. Không có vấn đề như vậy trong FP.

Gỡ lỗi

Nếu một chương trình chức năng không hoạt động như bạn mong đợi thì việc gỡ lỗi chỉ là chuyện dễ dàng. Bạn luôn có thể tái tạo vấn đề vì lỗi trong hàm không phụ thuộc vào mã nước ngoàiđã được thực hiện trước đó. Trong chương trình mệnh lệnh, lỗi chỉ xuất hiện trong một thời gian. Bạn sẽ phải trải qua một số bước không có lỗi do hoạt động của hàm phụ thuộc vào trạng thái bên ngoài và tác dụng phụ của các hàm khác. Trong FP, tình huống đơn giản hơn nhiều - nếu giá trị trả về không chính xác thì nó sẽ luôn không chính xác, bất kể đoạn mã nào được thực thi trước đó.

Khi bạn tái hiện lỗi, hãy tìm nguồn của nó - nhiệm vụ tầm thường. Nó thậm chí còn tốt đẹp. Ngay khi bạn dừng chương trình, bạn sẽ có toàn bộ ngăn xếp cuộc gọi trước mặt. Bạn có thể xem các đối số cho từng lệnh gọi hàm, giống như trong ngôn ngữ mệnh lệnh. Với sự khác biệt là trong một chương trình mệnh lệnh, điều này là không đủ, bởi vì các hàm phụ thuộc vào giá trị của các trường, biến toàn cục và trạng thái của các lớp khác. Một hàm trong FP chỉ phụ thuộc vào các đối số của nó và thông tin này ở ngay trước mắt bạn! Hơn nữa, trong một chương trình mệnh lệnh, việc kiểm tra giá trị trả về là không đủ để biết liệu một đoạn mã có hoạt động chính xác hay không. Bạn sẽ phải truy lùng hàng tá đối tượng bên ngoài hàm để đảm bảo mọi thứ hoạt động chính xác. Trong lập trình hàm, tất cả những gì bạn phải làm là nhìn vào giá trị trả về!

Khi duyệt qua ngăn xếp, bạn chú ý đến các đối số được truyền và giá trị trả về. Khi giá trị trả về lệch khỏi định mức, bạn sẽ đi sâu vào hàm và tiếp tục. Điều này được lặp lại nhiều lần cho đến khi bạn tìm ra nguồn gốc của lỗi!

Đa luồng

Chương trình chức năng ngay lập tức sẵn sàng để song song hóa mà không có bất kỳ thay đổi nào. Bạn không phải lo lắng về tình trạng bế tắc hoặc điều kiện cuộc đua vì bạn không cần khóa! Không một phần dữ liệu nào trong một chương trình chức năng bị thay đổi hai lần bởi cùng một luồng hoặc bởi các luồng khác nhau. Điều này có nghĩa là bạn có thể dễ dàng thêm các luồng vào chương trình của mình mà không phải lo lắng về các vấn đề cố hữu trong các ngôn ngữ mệnh lệnh.

Nếu đúng như vậy thì tại sao các ngôn ngữ lập trình hàm lại hiếm khi được sử dụng trong các ứng dụng đa luồng? Trên thực tế thường xuyên hơn bạn nghĩ. Ericsson đã phát triển một ngôn ngữ chức năng có tên Erlang để sử dụng trên các thiết bị chuyển mạch viễn thông có khả năng chịu lỗi và có thể mở rộng. Nhiều người ghi nhận những ưu điểm của Erlang và bắt đầu sử dụng nó. Chúng ta đang nói về các hệ thống viễn thông và điều khiển giao thông, những hệ thống gần như không thể mở rộng dễ dàng như các hệ thống điển hình được phát triển ở Phố Wall. Trên thực tế, các hệ thống viết bằng Erlang không có khả năng mở rộng và đáng tin cậy như Hệ thống Java. Hệ thống Erlang đơn giản là siêu đáng tin cậy.

Câu chuyện đa luồng không kết thúc ở đó. Nếu bạn đang viết một ứng dụng đơn luồng, trình biên dịch vẫn có thể tối ưu hóa chương trình chức năng để sử dụng nhiều CPU. Chúng ta hãy xem đoạn mã tiếp theo.


Trình biên dịch ngôn ngữ chức năng có thể phân tích mã, phân loại các hàm tạo ra dòng s1 và s2 là các hàm tốn thời gian và chạy chúng song song. Điều này không thể thực hiện được bằng ngôn ngữ mệnh lệnh, vì mỗi hàm có thể thay đổi trạng thái bên ngoài và mã ngay sau lệnh gọi có thể phụ thuộc vào nó. Trong FP, việc tự động phân tích các hàm và tìm kiếm các ứng viên phù hợp để song song hóa là một nhiệm vụ tầm thường, giống như nội tuyến tự động! Theo nghĩa này, phong cách lập trình chức năng đáp ứng các yêu cầu Ngày mai. Các nhà phát triển phần cứng không còn có thể làm cho CPU hoạt động nhanh hơn nữa. Thay vào đó, họ đang tăng số lượng lõi và tuyên bố tốc độ tính toán đa luồng tăng gấp bốn lần. Tất nhiên, họ quên nói đúng lúc rằng bạn bộ xử lý mới sẽ chỉ cho thấy sự gia tăng trong các chương trình được phát triển có tính đến việc song song hóa. Có rất ít những thứ này trong số các phần mềm bắt buộc. Nhưng 100% các chương trình chức năng đã sẵn sàng cho đa luồng.

Triển khai nóng

Ngày xưa cài đặt Cập nhật Windows Tôi đã phải khởi động lại máy tính. Nhiều lần. Sau khi cài đặt phiên bản mới của trình phát media. Đã có những thay đổi đáng kể trong Windows XP, nhưng tình hình vẫn chưa đạt đến mức lý tưởng (hôm nay tôi đã đưa ra cập nhật hệ điều hành Window tại nơi làm việc và bây giờ lời nhắc nhở khó chịu sẽ không để tôi yên cho đến khi tôi khởi động lại). TRONG Hệ thống Unix mô hình cập nhật đã tốt hơn. Để cài đặt các bản cập nhật, tôi phải dừng một số thành phần chứ không phải toàn bộ hệ điều hành. Mặc dù tình hình có vẻ tốt hơn nhưng nó vẫn không được chấp nhận đối với một lượng lớn ứng dụng máy chủ. Hệ thống viễn thông phải được bật 100% mọi lúc, vì nếu một bản cập nhật ngăn cản một người gọi xe cấp cứu thì có thể mất mạng. Các công ty Phố Wall cũng không muốn tắt máy chủ vào cuối tuần để cài đặt các bản cập nhật.

Lý tưởng nhất là bạn cần cập nhật tất cả các phần cần thiết của mã mà không dừng hệ thống về nguyên tắc. Trong thế giới mệnh lệnh, điều này là không thể [transl. trong Smalltalk thì điều đó là rất có thể]. Hãy tưởng tượng bạn đang tải một lớp Java xuống và tải lại một phiên bản mới. Nếu chúng ta làm điều này thì tất cả các phiên bản của lớp sẽ không hoạt động vì trạng thái mà chúng lưu trữ sẽ bị mất. Chúng tôi sẽ phải viết mã phức tạp để kiểm soát phiên bản. Chúng ta sẽ phải tuần tự hóa tất cả các phiên bản đã tạo của lớp, sau đó hủy chúng, tạo các phiên bản của một lớp mới, cố gắng tải dữ liệu được tuần tự hóa với hy vọng quá trình di chuyển sẽ diễn ra suôn sẻ và các phiên bản mới sẽ hợp lệ. Và bên cạnh đó, mã di chuyển phải được viết thủ công mỗi lần. Và mã di chuyển phải bảo toàn các liên kết giữa các đối tượng. Về lý thuyết thì ổn, nhưng trên thực tế thì nó sẽ không bao giờ có tác dụng.

Trong một chương trình chức năng, tất cả trạng thái được lưu trữ trên ngăn xếp dưới dạng đối số của hàm. Điều này làm cho việc triển khai nóng dễ dàng hơn nhiều! Về cơ bản, tất cả những gì bạn cần làm là tính toán sự khác biệt giữa mã trên máy chủ sản xuất và phiên bản mới, đồng thời cài đặt các thay đổi trong mã. Phần còn lại sẽ được thực hiện tự động bởi các công cụ ngôn ngữ! Nếu bạn nghĩ đây là khoa học viễn tưởng, hãy suy nghĩ kỹ. Các kỹ sư làm việc với Erlang đã cập nhật hệ thống của họ trong nhiều năm mà không ngừng công việc.

Bằng chứng và tối ưu hóa được hỗ trợ bằng máy

Một đặc tính thú vị khác của ngôn ngữ lập trình hàm là chúng có thể được học từ quan điểm toán học. Vì ngôn ngữ chức năng là sự triển khai của một hệ thống hình thức nên tất cả các phép toán được sử dụng trên giấy đều có thể được áp dụng cho các chương trình chức năng. Ví dụ, trình biên dịch có thể chuyển đổi một đoạn mã thành một đoạn mã tương đương nhưng hiệu quả hơn, đồng thời chứng minh tính tương đương của chúng về mặt toán học. Cơ sở dữ liệu quan hệ dữ liệu đã được tối ưu hóa như vậy trong nhiều năm. Không có gì ngăn cản bạn sử dụng các kỹ thuật tương tự trong các chương trình thông thường.

Ngoài ra, bạn có thể sử dụng toán học để chứng minh tính đúng đắn của các phần trong chương trình của mình. Nếu muốn, bạn có thể viết các công cụ phân tích mã của mình và tự động tạo các bài kiểm tra Đơn vị cho các trường hợp đặc biệt! Chức năng này là vô giá đối với các hệ thống đá rắn. Khi phát triển hệ thống giám sát máy tạo nhịp tim hoặc quản lý không lưu, những công cụ như vậy là bắt buộc. Nếu sự phát triển của bạn không thuộc lĩnh vực ứng dụng quan trọng thì các công cụ kiểm tra tự động vẫn sẽ mang lại cho bạn một lợi thế to lớn so với đối thủ cạnh tranh.

Hàm bậc cao hơn

Hãy nhớ rằng, khi nói về lợi ích của FP, tôi đã lưu ý rằng “mọi thứ trông có vẻ ổn, nhưng sẽ vô ích nếu tôi phải viết bằng một ngôn ngữ vụng về mà mọi thứ đều là cuối cùng”. Đây là một quan niệm sai lầm. Việc sử dụng Final ở mọi nơi chỉ có vẻ vụng về trong các ngôn ngữ lập trình mệnh lệnh như Java. Các ngôn ngữ lập trình hàm hoạt động với các kiểu trừu tượng khác, những ngôn ngữ khiến bạn quên mất rằng bạn từng thích thay đổi các biến. Một công cụ như vậy là các hàm bậc cao hơn.

Trong FP, một hàm không giống như một hàm trong Java hoặc C. Nó là một siêu tập hợp - chúng có thể thực hiện những điều tương tự như các hàm Java và thậm chí còn hơn thế nữa. Giả sử chúng ta có một hàm trong C:

Int add(int i, int j) ( return i + j; )
Trong FP, hàm này không giống với hàm C thông thường. Hãy mở rộng trình biên dịch Java của chúng tôi để hỗ trợ ký hiệu này. Trình biên dịch phải biến phần khai báo hàm thành mã Java sau (hãy nhớ rằng luôn có một phần cuối ẩn ở mọi nơi):

Lớp add_function_t ( int add(int i, int j) ( return i + j; ) ) add_function_t add = new add_function_t();
Biểu tượng thêm không thực sự là một chức năng. Đây là một lớp học nhỏ với một phương pháp. Bây giờ chúng ta có thể chuyển add làm đối số cho các hàm khác. Chúng ta có thể viết nó thành một biểu tượng khác. Chúng ta có thể tạo các phiên bản của add_function_t trong thời gian chạy và chúng sẽ bị trình thu gom rác hủy nếu không còn cần thiết. Hàm trở thành đối tượng cơ bản, giống như số và chuỗi. Các hàm hoạt động trên các hàm (lấy chúng làm đối số) được gọi là các hàm bậc cao hơn. Đừng để điều này làm bạn sợ hãi. Khái niệm về hàm bậc cao gần giống với khái niệm về các lớp Java hoạt động lẫn nhau (chúng ta có thể chuyển các lớp này sang các lớp khác). Chúng ta có thể gọi chúng là “các lớp bậc cao hơn”, nhưng không ai bận tâm đến điều đó vì Java không có một cộng đồng học thuật nghiêm ngặt đằng sau nó.

Bạn nên sử dụng các hàm bậc cao như thế nào và khi nào? Tôi rất vui vì bạn đã hỏi. Bạn viết chương trình của mình dưới dạng một đoạn mã nguyên khối lớn mà không cần lo lắng về hệ thống phân cấp lớp. Nếu bạn thấy đoạn mã nào đó được lặp lại ở những vị trí khác nhau, bạn hãy di chuyển nó tới chức năng riêng biệt(may mắn thay, trường học vẫn dạy cách làm điều này). Nếu bạn nhận thấy rằng một số logic trong hàm của bạn sẽ hoạt động khác đi trong một số trường hợp thì bạn tạo một hàm bậc cao hơn. Bối rối? Đây là một ví dụ thực tế từ công việc của tôi.

Giả sử chúng ta có một đoạn mã Java nhận tin nhắn, chuyển đổi nó những cách khác và chuyển nó sang một máy chủ khác.

Void handMessage(Message msg) ( // ... msg.setClientCode("ABCD_123"); // ... sendMessage(msg); ) // ... )
Bây giờ hãy tưởng tượng rằng hệ thống đã thay đổi và bây giờ bạn cần phân phối tin nhắn giữa hai máy chủ thay vì một. Mọi thứ vẫn không thay đổi ngoại trừ mã máy khách - máy chủ thứ hai muốn nhận mã này ở định dạng khác. Làm thế nào chúng ta có thể đối phó với tình huống này? Chúng ta có thể kiểm tra xem tin nhắn sẽ đi đâu và tùy vào điều này mà đặt mã đúng khách hàng. Ví dụ như thế này:

Lớp MessageHandler ( void handMessage(Message msg) ( // ... if(msg.getDestination().equals("server1") ( msg.setClientCode("ABCD_123"); ) else ( msg.setClientCode("123_ABC") ; ) // ... sendMessage(msg); ) // ... )
Nhưng cách tiếp cận này không có quy mô tốt. Khi các máy chủ mới được thêm vào, tính năng này sẽ phát triển tuyến tính và việc thực hiện các thay đổi sẽ trở thành một cơn ác mộng. Sự vật cách tiếp cận định hướng bao gồm việc tách biệt một siêu lớp chung MessageHandler và phân lớp logic để xác định mã máy khách:

Lớp trừu tượng MessageHandler ( void handMessage(Message msg) ( // ... msg.setClientCode(getClientCode()); // ... sendMessage(msg); ) chuỗi trừu tượng getClientCode(); // ... ) lớp MessageHandlerOne mở rộng MessageHandler ( Chuỗi getClientCode() ( return "ABCD_123"; ) ) lớp MessageHandlerTwo mở rộng MessageHandler ( Chuỗi getClientCode() ( return "123_ABCD"; ) )
Bây giờ đối với mỗi máy chủ, chúng ta có thể tạo một thể hiện của lớp tương ứng. Việc thêm máy chủ mới trở nên thuận tiện hơn. Nhưng có rất nhiều văn bản cho một thay đổi nhỏ như vậy. Tôi đã phải tạo hai loại mới chỉ để thêm hỗ trợ cho các mã máy khách khác nhau! Bây giờ, hãy thực hiện tương tự bằng ngôn ngữ của chúng ta với sự hỗ trợ cho các hàm bậc cao hơn:

Lớp MessageHandler ( void handMessage(Message msg, Function getClientCode) ( // ... Message msg1 = msg.setClientCode(getClientCode()); // ... sendMessage(msg1); ) // ... ) String getClientCodeOne( ) ( return "ABCD_123"; ) Chuỗi getClientCodeTwo() ( return "123_ABCD"; ) MessageHandler handler = new MessageHandler(); handler.handleMessage(someMsg, getClientCodeOne);
Chúng tôi không tạo kiểu mới hoặc làm phức tạp hệ thống phân cấp lớp. Chúng ta chỉ đơn giản truyền hàm này dưới dạng tham số. Chúng tôi đã đạt được hiệu quả tương tự như trong phiên bản hướng đối tượng, nhưng có một số lợi thế. Chúng tôi không ràng buộc mình với bất kỳ hệ thống phân cấp lớp nào: chúng tôi có thể chuyển bất kỳ hàm nào khác vào thời gian chạy và thay đổi chúng bất kỳ lúc nào, trong khi vẫn duy trì mức độ mô-đun cao với ít mã hơn. Về cơ bản, trình biên dịch đã tạo ra keo dán hướng đối tượng cho chúng ta! Đồng thời, tất cả các ưu điểm khác của FP đều được bảo tồn. Tất nhiên, sự trừu tượng mà các ngôn ngữ chức năng cung cấp không dừng lại ở đó. Các hàm bậc cao hơn chỉ là sự khởi đầu

cà ri

Hầu hết những người tôi gặp đều đã đọc cuốn sách Các mẫu thiết kế của Gang of Four. Bất kỳ lập trình viên có lòng tự trọng nào cũng sẽ nói rằng cuốn sách này không gắn liền với bất kỳ ngôn ngữ cụ thể lập trình và các mẫu có thể áp dụng cho phát triển phần mềm nói chung. Đây là một tuyên bố cao quý. Nhưng tiếc là nó khác xa sự thật.

Ngôn ngữ chức năng có tính biểu cảm đáng kinh ngạc. Trong ngôn ngữ chức năng, bạn sẽ không cần các mẫu thiết kế vì ngôn ngữ này ở cấp độ cao đến mức bạn có thể dễ dàng bắt đầu lập trình theo các khái niệm loại bỏ tất cả các mẫu lập trình đã biết. Một trong những mẫu này là Adaptor (nó khác với Facade như thế nào? Có vẻ như cần ai đó đóng dấu nhiều trang hơn thực hiện đúng các điều khoản của hợp đồng). Mẫu này hóa ra không cần thiết nếu ngôn ngữ hỗ trợ cà ri.

Mẫu Adaptor thường được áp dụng cho đơn vị trừu tượng "tiêu chuẩn" trong Java - lớp. Trong các ngôn ngữ chức năng, mẫu này được áp dụng cho các hàm. Mẫu lấy một giao diện và biến đổi nó thành một giao diện khác theo các yêu cầu nhất định. Dưới đây là một ví dụ về mẫu Adaptor:

Int pow(int i, int j); int vuông(int i) ( return pow(i, 2); )
Mã này điều chỉnh giao diện của hàm nâng một số lên lũy thừa tùy ý phù hợp với giao diện của hàm bình phương một số. Trong giới học thuật, kỹ thuật đơn giản này được gọi là cà ri (theo tên nhà logic học Haskell Curry, người đã thực hiện một loạt thủ thuật toán học để chính thức hóa tất cả). Vì các hàm được sử dụng ở mọi nơi làm đối số trong FP, nên Currying được sử dụng rất thường xuyên để đưa các hàm đến giao diện cần thiết ở nơi này hay nơi khác. Vì giao diện của một hàm là các đối số của nó nên Currying được sử dụng để giảm số lượng đối số (như trong ví dụ trên).

Công cụ này được tích hợp vào các ngôn ngữ chức năng. Bạn không cần phải tạo một hàm bao bọc bản gốc theo cách thủ công. Một ngôn ngữ chức năng sẽ làm mọi thứ cho bạn. Như thường lệ, hãy mở rộng ngôn ngữ của chúng ta bằng cách thêm cà ri.

Hình vuông = int pow(int i, 2);
Với dòng này, chúng ta sẽ tự động tạo một hàm bình phương với một đối số. Hàm mới sẽ gọi hàm pow, thay thế 2 làm đối số thứ hai. Từ góc độ Java, nó sẽ trông như thế này:

Lớp Square_function_t ( int Square(int i) ( return pow(i, 2); ) ) Square_function_t Square = new Square_function_t();
Như bạn có thể thấy, chúng tôi chỉ viết một trình bao bọc trên hàm ban đầu. Trong FP, cà ri chỉ là một cách đơn giản và thuận tiện để tạo các lớp bao bọc. Bạn tập trung vào nhiệm vụ và trình biên dịch sẽ viết mã cần thiết cho bạn! Việc này rất đơn giản và xảy ra mỗi khi bạn muốn sử dụng mẫu Adaptor (trình bao bọc).

Đánh giá lười biếng

Đánh giá lười biếng (hoặc trì hoãn) là kỹ thuật thú vịđiều này trở nên khả thi khi bạn đã nắm vững triết lý chức năng. Chúng ta đã thấy đoạn mã sau khi nói về đa luồng:

Chuỗi s1 = phần nàoLongOperation1(); Chuỗi s2 = phần nàoLongOperation2(); Chuỗi s3 = nối(s1, s2);
Trong các ngôn ngữ lập trình mệnh lệnh, thứ tự tính toán không đặt ra bất kỳ câu hỏi nào. Vì mỗi hàm có thể ảnh hưởng hoặc phụ thuộc vào trạng thái bên ngoài nên cần phải duy trì thứ tự lệnh gọi rõ ràng: đầu tiên là phần nàoLongOperation1, sau đó là phần nàoLongOperation2 và nối ở cuối. Nhưng không phải mọi thứ đều đơn giản như vậy trong các ngôn ngữ chức năng.

Như chúng ta đã thấy trước đó, phần nàoLongOperation1 và phần nàoLongOperation2 có thể chạy đồng thời vì các hàm được đảm bảo không ảnh hưởng hoặc phụ thuộc vào trạng thái chung. Nhưng nếu chúng ta không muốn thực thi chúng cùng lúc thì có nên gọi chúng theo thứ tự không? Câu trả lời là không. Những phép tính này chỉ nên được chạy nếu bất kỳ hàm nào khác phụ thuộc vào s1 và s2 . Chúng ta thậm chí không cần thực thi chúng cho đến khi chúng ta cần chúng bên trong concatenate . Nếu thay vì ghép nối, chúng ta thay thế một hàm, tùy theo điều kiện, sử dụng một trong hai đối số, thì đối số thứ hai thậm chí có thể không được tính toán! Haskell là một ví dụ về ngôn ngữ đánh giá lười biếng. Haskell không đảm bảo bất kỳ lệnh gọi nào (hoàn toàn không!) vì Haskell thực thi mã khi cần.

Tính toán lười biếng có một số ưu điểm cũng như một số nhược điểm. Trong phần tiếp theo, chúng ta sẽ thảo luận về những ưu điểm và tôi sẽ giải thích cách chấp nhận những nhược điểm.

Tối ưu hóa

Đánh giá lười biếng mang lại tiềm năng to lớn cho việc tối ưu hóa. Một trình biên dịch lười biếng xem xét mã giống như cách một nhà toán học xem xét các biểu thức đại số - nó có thể hoàn tác mọi thứ, hủy bỏ việc thực thi một số phần mã nhất định, thay đổi thứ tự các lệnh gọi để có hiệu quả cao hơn, thậm chí sắp xếp mã theo cách để giảm số lượng lỗi mà vẫn đảm bảo tính toàn vẹn của chương trình. Chính xác là thế này lợi thế lớn khi mô tả một chương trình bằng cách sử dụng các nguyên hàm hình thức nghiêm ngặt, mã tuân theo các định luật toán học và có thể được nghiên cứu bằng các phương pháp toán học.

Tóm tắt cấu trúc điều khiển

Tính toán lười biếng cung cấp mức độ trừu tượng cao đến mức có thể thực hiện được những điều tuyệt vời. Ví dụ, hãy tưởng tượng thực hiện cấu trúc điều khiển sau:

Trừ khi(stock.isEuropean()) ( sendToSEC(stock); )
Chúng tôi muốn hàm sendToSEC chỉ được thực thi nếu cổ phiếu không phải ở Châu Âu. Làm thế nào bạn có thể thực hiện trừ khi ? Nếu không đánh giá lười biếng, chúng ta sẽ cần một hệ thống macro, nhưng trong các ngôn ngữ như Haskell thì điều này là không cần thiết. Chúng ta có thể khai báotrừ khi là một hàm!

Vô hiệu trừ khi(điều kiện boolean, Mã danh sách) ( mã if(!condition); )
Lưu ý rằng mã sẽ không được thực thi nếu condition == true . Trong các ngôn ngữ nghiêm ngặt, hành vi này không thể lặp lại vì các đối số sẽ được đánh giá trước trừ khi được gọi.

Cấu trúc dữ liệu vô hạn

Ngôn ngữ lười biếng cho phép bạn tạo cấu trúc dữ liệu vô hạn, điều này khó tạo hơn nhiều trong các ngôn ngữ nghiêm ngặt. - chỉ là không có trong Python]. Ví dụ, hãy tưởng tượng dãy Fibonacci. Rõ ràng, chúng ta không thể tính một danh sách vô hạn trong một thời gian hữu hạn mà vẫn lưu trữ nó trong bộ nhớ. Trong các ngôn ngữ nghiêm ngặt như Java, chúng ta chỉ cần viết một hàm trả về một thành viên tùy ý của chuỗi. Trong các ngôn ngữ như Haskell, chúng ta có thể trừu tượng hóa và chỉ cần khai báo một danh sách vô hạn các số Fibonacci. Vì ngôn ngữ lười biếng nên chỉ những phần cần thiết của danh sách thực sự được sử dụng trong chương trình mới được tính toán. Điều này cho phép bạn tóm tắt một số lượng lớn các vấn đề và xem xét chúng từ cấp độ cao hơn (ví dụ: bạn có thể sử dụng các hàm để xử lý danh sách trên các chuỗi vô hạn).

sai sót

Tất nhiên, pho mát miễn phí chỉ có trong bẫy chuột. Tính toán lười biếng đi kèm với một số nhược điểm. Đây chủ yếu là những thiếu sót do lười biếng. Trong thực tế, thứ tự tính toán trực tiếp thường rất cần thiết. Lấy ví dụ đoạn mã sau:


Trong ngôn ngữ lười biếng, không ai đảm bảo rằng dòng đầu tiên sẽ được thực thi trước dòng thứ hai! Điều này có nghĩa là chúng ta không thể thực hiện I/O, chúng ta không thể sử dụng các hàm gốc một cách bình thường (xét cho cùng, chúng cần được gọi theo một thứ tự nhất định để giải quyết các tác dụng phụ của chúng) và chúng ta không thể tương tác với bên ngoài thế giới! Nếu chúng ta đưa ra một cơ chế ra lệnh thực thi mã, chúng ta sẽ mất đi lợi thế về tính chính xác về mặt toán học của mã (và khi đó chúng ta sẽ mất tất cả lợi ích của lập trình hàm). May mắn thay, tất cả không bị mất. Các nhà toán học đã bắt tay vào làm việc và đưa ra một số kỹ thuật để đảm bảo rằng các hướng dẫn được thực hiện theo đúng thứ tự mà không làm mất đi tinh thần chức năng. Chúng tôi đã có được điều tốt nhất của cả hai thế giới! Những kỹ thuật như vậy bao gồm các phần tiếp theo, các đơn nguyên và kiểu gõ duy nhất. Trong bài viết này, chúng tôi sẽ làm việc với các phần tiếp theo và để lại các đơn nguyên và cách gõ rõ ràng cho đến lần tiếp theo. Điều thú vị là phần tiếp theo là một thứ rất hữu ích, nó không chỉ được sử dụng để xác định thứ tự tính toán chặt chẽ. Chúng ta cũng sẽ nói về điều này.

Phần tiếp theo

Sự tiếp tục trong lập trình đóng vai trò tương tự như Mật mã Da Vinci trong lịch sử loài người: một tiết lộ đáng ngạc nhiên về bí ẩn lớn nhất của nhân loại. Chà, có thể không hẳn như vậy, nhưng chắc chắn là họ đã xé bỏ lớp bìa, giống như cách bạn đã học cách lấy gốc của -1 ngày xưa.

Khi xem xét các hàm, chúng ta chỉ biết được một nửa sự thật vì chúng ta giả định rằng một hàm trả về một giá trị cho hàm gọi nó. Theo nghĩa này, sự tiếp tục là sự khái quát hóa các chức năng. Một hàm không nhất thiết phải trả quyền điều khiển về vị trí mà nó được gọi nhưng có thể quay lại bất kỳ vị trí nào trong chương trình. "Tiếp tục" là tham số chúng ta có thể truyền cho hàm để biểu thị điểm trả về. Nghe có vẻ đáng sợ hơn nhiều so với thực tế. Chúng ta hãy xem đoạn mã sau:

Int i = cộng(5, 10); int j = hình vuông(i);
Hàm add trả về số 15, được ghi vào i tại vị trí hàm được gọi. Giá trị của i sau đó được sử dụng khi gọi hình vuông. Lưu ý rằng trình biên dịch lười biếng không thể thay đổi thứ tự tính toán, vì dòng thứ hai phụ thuộc vào kết quả của dòng đầu tiên. Chúng ta có thể viết lại mã này bằng cách sử dụng Kiểu chuyển tiếp tục (CPS), trong đó add trả về một giá trị cho hàm bình phương.

Int j = cộng(5, 10, bình phương);
Trong trường hợp này, add nhận được một đối số bổ sung - một hàm sẽ được gọi sau khi add chạy xong. Trong cả hai ví dụ j sẽ bằng 225.

Đây là kỹ thuật đầu tiên cho phép bạn chỉ định thứ tự thực hiện hai biểu thức. Hãy quay lại ví dụ I/O của chúng ta.

System.out.println("Xin vui lòng nhập tên của bạn: "); System.in.readLine();
Hai dòng này độc lập với nhau và trình biên dịch có thể tự do thay đổi thứ tự của chúng theo ý muốn. Nhưng nếu chúng ta viết lại nó trong CPS, thì chúng ta sẽ thêm phần phụ thuộc cần thiết và trình biên dịch sẽ phải thực hiện các phép tính lần lượt!

System.out.println("Xin vui lòng nhập tên của bạn: ", System.in.readLine);
Trong trường hợp này, println sẽ phải gọi readLine , chuyển kết quả của nó và trả về kết quả của readLine ở cuối. Trong biểu mẫu này, chúng ta có thể chắc chắn rằng các hàm này sẽ được gọi lần lượt và readLine hoàn toàn sẽ được gọi (xét cho cùng, trình biên dịch mong đợi nhận được kết quả). hoạt động cuối cùng). Trong trường hợp Java, println trả về void. Nhưng nếu một giá trị trừu tượng nào đó (có thể dùng làm đối số cho readLine) được trả về, điều đó sẽ giải quyết được vấn đề của chúng ta! Tất nhiên, việc xây dựng chuỗi chức năng như vậy sẽ làm giảm đáng kể khả năng đọc mã, nhưng điều này có thể giải quyết được. Chúng ta có thể thêm các tính năng cú pháp vào ngôn ngữ của mình để cho phép chúng ta viết các biểu thức như bình thường và trình biên dịch sẽ tự động xâu chuỗi các phép tính. Giờ đây, chúng ta có thể thực hiện các phép tính theo bất kỳ thứ tự nào mà không làm mất đi các lợi thế của FP (bao gồm khả năng nghiên cứu chương trình bằng các phương pháp toán học)! Nếu điều này gây nhầm lẫn, hãy nhớ rằng các hàm chỉ là các thể hiện của một lớp có một thành viên duy nhất. Viết lại ví dụ của chúng ta sao cho println và readLine là các thể hiện của các lớp, điều này sẽ giúp bạn hiểu rõ hơn.

Nhưng lợi ích của các phần tiếp theo không dừng lại ở đó. Chúng ta có thể viết toàn bộ chương trình bằng CPS, sao cho mỗi hàm được gọi với một tham số bổ sung, phần tiếp theo, để truyền kết quả. Về nguyên tắc, bất kỳ chương trình nào cũng có thể được dịch sang CPS nếu mỗi chức năng được coi là trường hợp tiếp theo đặc biệt. Việc chuyển đổi này có thể được thực hiện tự động (trên thực tế, nhiều trình biên dịch làm như vậy).

Ngay khi chúng ta chuyển đổi chương trình sang dạng CPS, có thể thấy rõ rằng mỗi lệnh đều có phần tiếp theo, một hàm mà kết quả sẽ được truyền vào, trong đó chương trình thường xuyên sẽ là một điểm thách thức. Hãy lấy bất kỳ hướng dẫn nào từ ví dụ trước, ví dụ add(5,10) . Trong một chương trình được viết dưới dạng CPS, rõ ràng phần tiếp theo sẽ là gì - đây là hàm add sẽ gọi khi hoàn thành công việc. Nhưng điều gì sẽ tiếp tục trong trường hợp chương trình không phải CPS? Tất nhiên, chúng tôi có thể chuyển đổi chương trình sang CPS, nhưng điều này có cần thiết không?

Hóa ra điều này là không cần thiết. Hãy xem xét kỹ chuyển đổi CPS của chúng tôi. Nếu bạn bắt đầu viết trình biên dịch cho nó, bạn sẽ thấy rằng phiên bản CPS không cần ngăn xếp! Các hàm không bao giờ trả về bất cứ thứ gì, theo nghĩa truyền thống của từ “return”, chúng chỉ đơn giản gọi một hàm khác, thay thế kết quả của phép tính. Không cần phải đẩy các đối số vào ngăn xếp trước mỗi cuộc gọi và sau đó bật chúng trở lại. Chúng ta có thể chỉ cần lưu trữ các đối số ở một số vị trí bộ nhớ cố định và thay vào đó sử dụng lệnh nhảy cuộc gọi thường xuyên. Chúng ta không cần lưu trữ các đối số ban đầu, vì chúng sẽ không bao giờ cần thiết nữa, vì các hàm không trả về bất cứ thứ gì!

Do đó, các chương trình kiểu CPS không cần ngăn xếp mà chứa một đối số bổ sung, dưới dạng hàm, để được gọi. Các chương trình kiểu không phải CPS không có đối số bổ sung mà sử dụng ngăn xếp. Những gì được lưu trữ trên ngăn xếp? Chỉ cần các đối số và một con trỏ tới vị trí bộ nhớ nơi hàm sẽ trả về. Chà, bạn đã đoán được chưa? Ngăn xếp lưu trữ thông tin về sự tiếp tục! Một con trỏ tới điểm trả về trên ngăn xếp giống như hàm được gọi trong các chương trình CPS! Để biết phần tiếp theo của add(5,10) là gì, chỉ cần lấy điểm trả về từ ngăn xếp.

Nó không khó lắm. Phần tiếp theo và con trỏ tới điểm trả về thực sự giống nhau, chỉ phần tiếp theo được chỉ định rõ ràng và do đó nó có thể khác với vị trí mà hàm được gọi. Nếu bạn nhớ rằng phần tiếp theo là một hàm và một hàm trong ngôn ngữ của chúng ta được biên dịch thành một thể hiện của một lớp, thì bạn sẽ hiểu rằng một con trỏ tới điểm trả về trên ngăn xếp và một con trỏ tới phần tiếp theo thực sự giống nhau , bởi vì hàm của chúng ta (như một thể hiện của class ) chỉ là một con trỏ. Điều này có nghĩa là tại bất kỳ thời điểm nào trong chương trình của bạn, bạn có thể yêu cầu tiếp tục hiện tại (về cơ bản là thông tin từ ngăn xếp).

Được rồi, bây giờ chúng ta đã hiểu sự tiếp tục hiện tại là gì. Nó có nghĩa là gì? Nếu chúng ta lấy phần tiếp theo hiện tại và lưu nó ở đâu đó, thì chúng ta sẽ lưu trạng thái hiện tại của chương trình - chúng ta sẽ đóng băng nó. Điều này tương tự như chế độ ngủ đông của hệ điều hành. Đối tượng tiếp tục lưu trữ thông tin cần thiết để tiếp tục thực hiện chương trình từ thời điểm đối tượng tiếp tục được yêu cầu. hệ điều hành thực hiện điều này với các chương trình của bạn mọi lúc khi nó chuyển ngữ cảnh giữa các luồng. Điểm khác biệt duy nhất là mọi thứ đều nằm dưới sự kiểm soát của HĐH. Nếu bạn yêu cầu một đối tượng tiếp tục (trong Lược đồ, điều này được thực hiện bằng cách gọi hàm gọi tiếp theo hiện tại), thì bạn sẽ nhận được một đối tượng có phần tiếp tục hiện tại - ngăn xếp (hoặc trong trường hợp CPS, hàm gọi tiếp theo ). Bạn có thể lưu đối tượng này vào một biến (hoặc thậm chí vào đĩa). Nếu bạn quyết định "khởi động lại" chương trình với phần tiếp theo này, thì trạng thái chương trình của bạn sẽ được "chuyển đổi" sang trạng thái tại thời điểm đối tượng tiếp tục được lấy. Điều này cũng giống như việc chuyển sang một luồng bị treo hoặc đánh thức HĐH sau khi ngủ đông. Ngoại trừ việc bạn có thể làm điều này nhiều lần liên tiếp. Sau khi hệ điều hành thức dậy, thông tin ngủ đông sẽ bị hủy. Nếu điều này không được thực hiện thì có thể khôi phục trạng thái hệ điều hành từ cùng một điểm. Nó gần giống như du hành xuyên thời gian. Với phần tiếp theo, bạn có thể mua được!

Việc tiếp tục sẽ hữu ích trong những tình huống nào? Thông thường nếu bạn đang cố gắng mô phỏng trạng thái trong các hệ thống vốn không có trạng thái. Một cách sử dụng tuyệt vời cho tính năng tiếp tục đã được tìm thấy trong các ứng dụng Web (ví dụ: trong khung công tác Seaside cho ngôn ngữ Smalltalk). ASP.NET của Microsoft nỗ lực hết sức để lưu trạng thái giữa các yêu cầu nhằm giúp cuộc sống của bạn dễ dàng hơn. Nếu C# hỗ trợ các phần tiếp theo, độ phức tạp của ASP.NET có thể giảm đi một nửa bằng cách chỉ cần lưu phần tiếp theo và khôi phục nó trong yêu cầu tiếp theo. Theo quan điểm của một lập trình viên Web, sẽ không có một khoảng nghỉ nào - chương trình sẽ tiếp tục công việc của mình với hàng tiếp theo! Tiếp tục là một sự trừu tượng cực kỳ hữu ích để giải quyết một số vấn đề. Với việc ngày càng có nhiều khách hàng truyền thống béo chuyển sang Web, tầm quan trọng của việc tiếp tục sẽ chỉ tăng lên theo thời gian.

Khớp mẫu

Kết hợp mẫu không phải là một ý tưởng mới hoặc sáng tạo. Trên thực tế, nó ít liên quan đến lập trình chức năng. Lý do duy nhất nó thường được liên kết với FP là vì hiện nay các ngôn ngữ chức năng có tính năng khớp mẫu, nhưng các ngôn ngữ mệnh lệnh thì không.

Hãy bắt đầu phần giới thiệu của chúng tôi về Khớp mẫu bằng ví dụ sau. Đây là hàm tính số Fibonacci trong Java:

Int fib(int n) ( if(n == 0) return 1; if(n == 1) return 1; return fib(n - 2) + fib(n - 1); )
Và đây là một ví dụ bằng ngôn ngữ giống Java có hỗ trợ Khớp mẫu

Int fib(0) ( return 1; ) int fib(1) ( return 1; ) int fib(int n) ( return fib(n - 2) + fib(n - 1); )
Sự khác biệt là gì? Trình biên dịch thực hiện phân nhánh cho chúng ta.

Hãy nghĩ xem, nó rất quan trọng! Nó thực sự không quan trọng lắm. Nó đã được lưu ý rằng một số lượng lớn các hàm chứa các cấu trúc chuyển đổi phức tạp (điều này đúng một phần với các chương trình chức năng) và người ta quyết định nhấn mạnh điểm này. Định nghĩa hàm được chia thành nhiều biến thể và một mẫu được thiết lập thay cho các đối số của hàm (điều này gợi nhớ đến việc nạp chồng phương thức). Khi một lệnh gọi hàm xảy ra, trình biên dịch sẽ so sánh nhanh các đối số với tất cả các định nghĩa và chọn đối số phù hợp nhất. Thông thường sự lựa chọn rơi vào định nghĩa hàm chuyên biệt nhất. Ví dụ: int fib(int n) có thể được gọi khi n bằng 1, nhưng sẽ không, vì int fib(1) là một định nghĩa chuyên biệt hơn.

Việc khớp mẫu thường trông phức tạp hơn trong ví dụ của chúng tôi. Ví dụ một hệ thống phức tạp Khớp mẫu cho phép bạn viết đoạn mã sau:

Int f(int n< 10) { ... } int f(int n) { ... }
Khi nào việc khớp mẫu hữu ích? Danh sách những trường hợp như vậy dài đến mức đáng ngạc nhiên! Bất cứ khi nào bạn sử dụng các cấu trúc if lồng nhau phức tạp, việc khớp mẫu có thể thực hiện công việc tốt hơn với ít mã hơn. Một ví dụ điển hình mà tôi nghĩ đến là hàm WndProc, được triển khai trong mọi chương trình Win32 (ngay cả khi nó bị ẩn khỏi lập trình viên đằng sau hàng rào trừu tượng cao). Thông thường, việc khớp mẫu thậm chí có thể kiểm tra nội dung của bộ sưu tập. Ví dụ: nếu bạn truyền một mảng cho một hàm thì bạn có thể chọn tất cả các mảng có phần tử đầu tiên bằng 1 và phần tử thứ ba lớn hơn 3.

Một ưu điểm khác của So khớp mẫu là nếu bạn thực hiện thay đổi, bạn sẽ không phải tìm hiểu kỹ một hàm lớn. Tất cả những gì bạn cần làm là thêm (hoặc thay đổi) một số định nghĩa hàm. Như vậy, chúng ta đã loại bỏ được cả một lớp hoa văn từ cuốn sách nổi tiếng Gang of Four. Các điều kiện càng phức tạp và phân nhánh thì việc sử dụng Đối sánh mẫu càng hữu ích. Khi bạn bắt đầu sử dụng chúng, bạn sẽ tự hỏi làm thế nào bạn có thể quản lý được mà không có chúng.

Đóng cửa

Cho đến nay, chúng ta đã thảo luận về các tính năng của FP trong bối cảnh các ngôn ngữ chức năng “thuần túy” - những ngôn ngữ triển khai phép tính lambda và không chứa các tính năng mâu thuẫn với hệ thống Giáo hội chính thức. Tuy nhiên, nhiều tính năng của ngôn ngữ hàm được sử dụng ngoài phép tính lambda. Mặc dù việc triển khai một hệ tiên đề rất thú vị xét từ quan điểm lập trình về mặt biểu thức toán học, nhưng điều này có thể không phải lúc nào cũng có thể áp dụng được trong thực tế. Nhiều ngôn ngữ thích sử dụng các thành phần của ngôn ngữ chức năng mà không tuân theo học thuyết chức năng nghiêm ngặt. Một số ngôn ngữ như vậy (ví dụ Common Lisp) không yêu cầu các biến phải là giá trị cuối cùng - giá trị của chúng có thể được thay đổi. Họ thậm chí không yêu cầu các hàm chỉ phụ thuộc vào đối số của chúng—các hàm được phép truy cập trạng thái ngoài phạm vi của chúng. Nhưng đồng thời chúng bao gồm các tính năng như hàm bậc cao hơn. Việc truyền hàm bằng ngôn ngữ không thuần túy hơi khác một chút so với thao tác tương đương trong phép tính lambda và yêu cầu một tính năng thú vị được gọi là đóng từ vựng. Chúng ta hãy xem ví dụ sau. Hãy nhớ rằng trong trong trường hợp này các biến không phải là cuối cùng và một hàm có thể truy cập các biến nằm ngoài phạm vi của nó:

Hàm makePowerFn(int power) ( int powerFn(int base) ( return pow(base, power); ) trả về powerFn; ) Hàm bình phương = makePowerFn(2); hình vuông(3); // trả về 9
Hàm make-power-fn trả về một hàm nhận vào một đối số và nâng nó lên một lũy thừa nhất định. Điều gì xảy ra khi chúng ta cố gắng tính bình phương (3)? Biến power nằm ngoài phạm vi của powerFn vì makePowerFn đã hoàn thành và ngăn xếp của nó đã bị hủy. Vậy hình vuông hoạt động như thế nào? Ngôn ngữ bằng cách nào đó phải lưu trữ ý nghĩa của sức mạnh để hàm bình phương hoạt động. Điều gì sẽ xảy ra nếu chúng ta tạo một hàm lập phương khác để nâng một số lên lũy thừa ba? Ngôn ngữ sẽ phải lưu trữ hai giá trị power cho mỗi hàm được tạo trong make-power-fn. Hiện tượng lưu trữ các giá trị này được gọi là đóng cửa. Việc đóng không chỉ bảo toàn các đối số của hàm top. Ví dụ: một bao đóng có thể trông như thế này:

Hàm makeIncrementer() ( int n = 0; int tăng() ( return ++n; ) ) Hàm inc1 = makeIncrementer(); Hàm inc2 = makeIncrementer(); inc1(); // trả về 1; inc1(); // trả về 2; inc1(); // trả về 3; inc2(); // trả về 1; inc2(); // trả về 2; inc2(); // trả về 3;
Trong quá trình thực thi, các giá trị của n được lưu trữ và bộ đếm có quyền truy cập vào chúng. Hơn nữa, mỗi bộ đếm có bản sao n riêng, mặc dù thực tế là chúng lẽ ra phải biến mất sau khi hàm makeIncrementer chạy. Trình biên dịch quản lý việc biên dịch này như thế nào? Điều gì xảy ra đằng sau hậu trường đóng cửa? May mắn thay chúng ta có một đường chuyền thần kỳ.

Mọi thứ được thực hiện khá logic. Thoạt nhìn, rõ ràng là các biến cục bộ không còn tuân theo các quy tắc phạm vi nữa và thời gian tồn tại của chúng không được xác định. Rõ ràng, chúng không còn được lưu trữ trên ngăn xếp nữa - chúng cần được giữ trên đống. Do đó, việc đóng được thực hiện giống như hàm thông thường mà chúng ta đã thảo luận trước đó, ngoại trừ việc nó có một tham chiếu bổ sung đến các biến xung quanh:

Lớp some_function_t ( SymbolTable parentScope; // ... )
Nếu một bao đóng truy cập vào một biến không nằm trong phạm vi cục bộ thì nó sẽ tính đến phạm vi cha. Đó là tất cả! Closure kết nối thế giới chức năng với thế giới OOP. Mỗi khi bạn tạo một lớp lưu trữ một số trạng thái và chuyển nó đi đâu đó, hãy nhớ về các bao đóng. Việc đóng chỉ là một đối tượng tạo ra các "thuộc tính" một cách nhanh chóng, đưa chúng ra khỏi phạm vi để bạn không phải tự mình thực hiện.

Giờ thì sao?

Bài viết này chỉ đề cập đến phần nổi của tảng băng chìm Lập trình chức năng. Bạn có thể tìm hiểu sâu hơn và thấy điều gì đó thực sự lớn lao, và trong trường hợp của chúng tôi, điều gì đó tốt đẹp. Trong tương lai, tôi dự định viết về lý thuyết danh mục, đơn nguyên, cấu trúc dữ liệu chức năng, hệ thống kiểu trong ngôn ngữ chức năng, đa luồng chức năng, cơ sở dữ liệu chức năng và nhiều thứ khác. Nếu tôi có thể viết (và nghiên cứu trong quá trình đó) về một nửa số chủ đề này thì cuộc đời tôi sẽ không vô ích. Trong lúc đó, Google- người bạn trung thành của bạn.

Tôi luôn muốn viết một loạt bài về lập trình chức năng cho tạp chí này và tôi rất vui vì cuối cùng tôi cũng có được cơ hội. Mặc dù loạt bài về phân tích dữ liệu của tôi vẫn chưa kết thúc :). Tôi sẽ không công bố nội dung của toàn bộ loạt bài, tôi chỉ nói rằng hôm nay chúng ta sẽ nói về các ngôn ngữ lập trình khác nhau hỗ trợ phong cách chức năng và kỹ thuật lập trình tương ứng.

Ngôn ngữ lập trình không phải ai cũng biết

Tôi bắt đầu lập trình khi còn nhỏ và đến năm 25 tuổi, tôi dường như đã biết và hiểu mọi thứ. Lập trình hướng đối tượng đã trở thành một phần trong bộ não của tôi, mọi cuốn sách có thể tưởng tượng được về lập trình công nghiệp đều được đọc. Nhưng tôi vẫn có cảm giác mình đã bỏ lỡ điều gì đó, điều gì đó rất tế nhị và vô cùng quan trọng. Thực tế là, giống như nhiều người trong những năm 90, ở trường, tôi được dạy lập trình bằng Pascal (ồ vâng, vinh quang cho Turbo Pascal 5.5! - Ed.), sau đó là C và C++. Tại trường đại học, Fortran và sau đó là Java là công cụ chính trong công việc. Tôi biết Python và một số ngôn ngữ khác, nhưng tất cả đều sai. Nhưng tôi không được đào tạo nghiêm túc về lĩnh vực Khoa học Máy tính. Một ngày nọ, trong chuyến bay xuyên Đại Tây Dương, tôi không thể ngủ được và muốn đọc một cái gì đó. Bằng cách nào đó, thật kỳ diệu, tôi đã có trong tay một cuốn sách về ngôn ngữ lập trình Haskell. Đối với tôi, dường như chính lúc đó tôi mới hiểu được ý nghĩa thực sự của câu nói “vẻ đẹp đòi hỏi sự hy sinh”.

Bây giờ, khi mọi người hỏi tôi học Haskell như thế nào, tôi nói thế này: trên máy bay. Tình tiết này đã thay đổi thái độ của tôi đối với việc lập trình nói chung. Tất nhiên, sau lần làm quen đầu tiên, nhiều điều dường như không hoàn toàn rõ ràng đối với tôi. Tôi đã phải căng thẳng và nghiên cứu vấn đề cẩn thận hơn. Và bạn biết đấy, mười năm đã trôi qua, rất nhiều yếu tố chức năngđã trở thành một phần của ngôn ngữ công nghiệp, hàm lambdađã tồn tại ngay cả trong Java, kiểu suy luận- trong C++, khớp mẫu- ở Scala. Nhiều người cho rằng đây là một bước đột phá. Và trong loạt bài viết này tôi sẽ nói với bạn về các kỹ thuật lập trình chức năng bằng cách sử dụng ngôn ngữ khác nhau và các tính năng của chúng.

Người dùng Internet thường biên soạn đủ loại danh sách và top để giải trí cho công chúng. Ví dụ: “danh sách những cuốn sách bạn nên đọc trước khi bước sang tuổi ba mươi”. Nếu tôi được giao nhiệm vụ lập danh sách những cuốn sách về lập trình mà bạn nên đọc trước khi lớn hơn, thì vị trí đầu tiên chắc chắn sẽ thuộc về cuốn sách của Abelson và Sussman. "Cấu trúc và giải thích chương trình máy tính". Đôi khi đối với tôi, thậm chí có vẻ như trình biên dịch hoặc trình thông dịch bất kì ngôn ngữ sẽ ngăn cản bất cứ ai chưa đọc cuốn sách này.

Do đó, nếu có một ngôn ngữ mà bạn cần bắt đầu học lập trình chức năng thì đó là Lisp. Nói chung, đây là cả một họ ngôn ngữ, bao gồm một ngôn ngữ khá phổ biến hiện nay dành cho JVM có tên là clojure. Nhưng nó không đặc biệt phù hợp làm ngôn ngữ chức năng đầu tiên. Để làm điều này tốt hơn là sử dụng ngôn ngữ Cơ chế, được phát triển tại MIT và cho đến giữa những năm 2000, nó vẫn là ngôn ngữ chính để giảng dạy lập trình. Mặc dù khóa học giới thiệu có cùng tiêu đề với cuốn sách được đề cập hiện đã được thay thế bằng khóa học về Python nhưng nó vẫn không mất đi sự liên quan.

Tôi sẽ cố gắng nói ngắn gọn về ngôn ngữ Đề án và nói chung về ý tưởng đằng sau các ngôn ngữ của nhóm này. Mặc dù thực tế là Lisp đã rất cũ (trong tất cả các ngôn ngữ cấp cao, chỉ có Fortran là cũ hơn), nhưng chính nhờ đó mà nhiều phương pháp lập trình được sử dụng ngày nay lần đầu tiên xuất hiện. Trong phần tiếp theo, tôi sẽ sử dụng tên Lisp, nghĩa là một cách triển khai cụ thể - Lược đồ.

Cú pháp trong hai phút

Cú pháp trong Lisp hơi gây tranh cãi. Thực tế là ý tưởng đằng sau cú pháp cực kỳ đơn giản và được xây dựng trên cơ sở cái gọi là biểu thức S. Đây là ký hiệu tiền tố trong đó biểu thức quen thuộc 2 + 3 được viết là (+ 2 3) . Điều này có vẻ lạ lùng nhưng trong thực tế nó mang lại một số Tính năng bổ sung. Nhân tiện, (+ 2 10 (* 3,14 2)) cũng hoạt động :). Do đó, toàn bộ chương trình là một tập hợp các danh sách sử dụng ký hiệu tiền tố. Trong trường hợp của ngôn ngữ Lisp, bản thân chương trình và cây cú pháp trừu tượng - “nếu bạn hiểu ý tôi” 😉 - về cơ bản không khác nhau. Kỷ lục như vậy khiến phân tích cú pháp Các chương trình Lisp rất đơn giản.
Vì chúng ta đang nói về một ngôn ngữ lập trình nên chúng ta nên nói về cách xác định các hàm trong ngôn ngữ này.

Ở đây chúng ta cần thực hiện một sự lạc đề nhỏ. Có một sự tinh tế mà tầm quan trọng của nó bị đánh giá thấp trong văn học hiện đại. Vẫn cần phải tách biệt hàm theo nghĩa toán học và hàm theo cách chúng ta hiểu trong lập trình hàm. Thực tế là trong toán học, các hàm là đối tượng khai báo và trong lập trình, chúng được sử dụng để tổ chức quá trình tính toán, nghĩa là, theo một nghĩa nào đó, chúng đại diện cho kiến ​​thức mệnh lệnh, kiến ​​thức trả lời câu hỏi “làm thế nào?” Đó là lý do tại sao Abelson và Sussman trong cuốn sách của họ đã phân biệt rất cẩn thận hàm này và hàm gọi trong quy trình lập trình. Điều này không được chấp nhận trong tài liệu lập trình chức năng hiện đại. Nhưng tôi vẫn thực sự khuyên bạn nên tách biệt hai nghĩa này của từ “chức năng” ít nhất là trong đầu bạn.

Cách dễ nhất để xác định hàm là viết đoạn mã sau. Hãy bắt đầu với một cái gì đó đơn giản một cách không đứng đắn:

(xác định (căn bậc hai a b c) (let ((D (- (* b b) (* 4 a c)))) (if (< D 0) (list) (let ((sqrtD (sqrt D))) (let ((x1 (/ (- (- b) sqrtD) (* 2.0 a))) (x2 (/ (+ (- b) sqrtD) (* 2.0 a)))) (list x1 x2))))))

Vâng, đó chính xác là những gì bạn nghĩ - giải phương trình bậc hai trong Sơ đồ. Nhưng điều này là quá đủ để thấy tất cả các tính năng của cú pháp. Ở đây căn bậc hai là tên của hàm từ ba tham số hình thức.

Thoạt nhìn, cấu trúc let, được sử dụng để xác định các biến cục bộ, có quá nhiều dấu ngoặc đơn. Nhưng điều này không đúng, trước tiên chúng ta chỉ cần xác định danh sách các biến và sau đó là biểu thức trong đó các biến này được sử dụng. Ở đây (danh sách) là một danh sách trống mà chúng tôi trả về khi không có gốc và (danh sách x1 x2) là danh sách gồm hai giá trị.

Bây giờ về biểu thức. Trong hàm căn bậc hai, chúng ta đã sử dụng cấu trúc if. Đây là nơi lập trình chức năng xuất hiện.

Vấn đề là, không giống như các ngôn ngữ mệnh lệnh như C, trong các ngôn ngữ chức năng if là một biểu thức chứ không phải một câu lệnh. Trong thực tế, điều này có nghĩa là nó không thể có nhánh else. Bởi vì một biểu thức luôn phải có ý nghĩa.

Bạn không thể nói về cú pháp mà không nói về cú pháp đặc biệt. Trong các ngôn ngữ lập trình, đường cú pháp đề cập đến các cấu trúc không cần thiết mà chỉ làm cho mã dễ đọc và tái sử dụng hơn. Để bắt đầu, hãy đưa ra một ví dụ cổ điển từ ngôn ngữ C. Nhiều người biết rằng mảng không phải là một phương tiện biểu đạt cần thiết vì đã có con trỏ. Đúng, thực sự, mảng được triển khai thông qua con trỏ và a[i] đối với ngôn ngữ C cũng giống như *(a + i) . Ví dụ này nói chung là khá bất thường, nó có một hiệu ứng buồn cười đi kèm với nó: vì phép cộng vẫn có tính giao hoán trong trường hợp con trỏ, nên biểu thức cuối cùng giống như *(i + a) và điều này có thể thu được bằng cách loại bỏ đường cú pháp khỏi biểu thức i [a] ! Hoạt động loại bỏ đường cú pháp trong tiếng anh gọi là từ đặc biệt khử đường.

Quay trở lại ngôn ngữ Đề án, chúng ta nên đưa ví dụ quan trọng cú pháp đặc biệt. Để xác định các biến, như trong trường hợp hàm, hãy sử dụng từ khóa(trong Lisp và Lược đồ, điều này được gọi là hình thức đặc biệt) định nghĩa . Ví dụ: (define pi 3.14159) định nghĩa biến pi. Nói chung, bạn có thể định nghĩa các hàm theo cùng một cách:

(xác định hình vuông (lambda (x) (* x x)))

điều này giống như

(xác định (vuông x) (* x x))

Dòng cuối cùng có vẻ dễ đọc hơn một chút so với phiên bản sử dụng biểu thức lambda. Tuy nhiên, rõ ràng chỉ cần có phương án thứ nhất là đủ, còn phương án thứ hai là tùy chọn. Tại sao điều đầu tiên lại quan trọng hơn? Bởi vì một trong những Các tính chất cơ bản các ngôn ngữ chức năng - các chức năng trong chúng là các đối tượng hạng nhất. Điều sau có nghĩa là các hàm có thể được truyền dưới dạng đối số và được trả về dưới dạng giá trị.

Nếu bạn nhìn let từ quan điểm của biểu thức lambda, bạn có thể dễ dàng nhận thấy sự tương ứng sau:

(let ((x 5) (y 2)) (* x y)) (áp dụng (lambda (x y) (* x y)) (danh sách 5 2))

Lập trình chức năng

Có các ngôn ngữ chức năng lau dọnô uế. Các ngôn ngữ chức năng thuần túy tương đối hiếm, chúng chủ yếu bao gồm HaskellLau dọn. Ngôn ngữ thuần túy không có tác dụng phụ. Trong thực tế, điều này có nghĩa là không có sự phân công hoặc I/O như chúng ta vẫn thường làm. Điều này tạo ra một số khó khăn, mặc dù trong các ngôn ngữ đã đề cập, điều này được giải quyết khá thông minh và trong các ngôn ngữ này họ viết mã bằng một lượng lớn Vào/ra Các ngôn ngữ như Lisp, OCaml hoặc Scala cho phép các hàm có tác dụng phụ và theo nghĩa này, các ngôn ngữ này thường thực tế hơn.

Nhiệm vụ của chúng ta là tìm hiểu các kỹ thuật cơ bản của lập trình hàm trong Đề án. Do đó, chúng tôi sẽ viết mã chức năng thuần túy mà không cần sử dụng trình tạo Số ngẫu nhiên, I/O và thiết lập! , điều này sẽ cho phép bạn thay đổi giá trị của các biến. Bạn có thể đọc về tất cả điều này trong cuốn sách SICP. Bây giờ hãy tập trung vào điều quan trọng nhất đối với chúng tôi.

Điều đầu tiên khiến người mới bắt đầu bối rối về lập trình chức năng là thiếu vòng lặp. Nhưng chúng ta nên làm gì? Nhiều người trong chúng ta được dạy rằng đệ quy là xấu. Điều này được lập luận bởi thực tế là đệ quy trong các ngôn ngữ lập trình thông thường thường được triển khai không hiệu quả. Vấn đề là ở chỗ trường hợp chung Người ta nên phân biệt giữa đệ quy như một kỹ thuật kỹ thuật, nghĩa là gọi một hàm từ chính nó và đệ quy như một quy trình. Các ngôn ngữ chức năng hỗ trợ tối ưu hóa đệ quy đuôi hoặc đôi khi được gọi là đệ quy tích lũy. Điều này có thể được minh họa bằng một ví dụ đơn giản.

Giả sử chúng ta có hai hàm - succ và prev. Cái đầu tiên trả về số lớn hơn đối số 1 và cái thứ hai trả về ít hơn 1. Bây giờ chúng ta hãy thử định nghĩa phép cộng theo hai cách:

(xác định (thêm x y) (if (eq? y 0) x (add (succ x) (prev y)))) (xác định (add-1 x y) (if (eq? y 0) x (succ (add- 1 x (trước y)))))

Sự khác biệt giữa trường hợp thứ nhất và thứ hai là gì? Thực tế là nếu chúng ta xem xét từng bước phương pháp tính toán cho trường hợp đầu tiên, chúng ta có thể thấy như sau:

(cộng 3 4) => (cộng 4 3) => (cộng 5 2) => (cộng 6 1) => (cộng 7 0) => 7

Trong trường hợp thứ hai, chúng ta sẽ có một cái gì đó như thế này:

(add-1 3 4) => (succ (add-1 3 3)) => (succ (succ (add-1 3 2))) => (succ (succ (succ (add-1 3 1)) )) => (succ (succ (succ (succ (add-1 3 0))))) => (succ (succ (succ (succ 3)))) => (succ (succ (succ 4))) => (thành công (thành công 5)) => (thành công 6) => 7

Mặc dù thực tế là trong cả hai trường hợp, kết quả đều giống nhau nhưng quá trình tính toán hoàn toàn khác nhau. Trong trường hợp đầu tiên, dung lượng bộ nhớ được sử dụng không thay đổi, nhưng trong trường hợp thứ hai, nó tăng tuyến tính. Quá trình đầu tiên là lặp đi lặp lại, và thứ hai - đệ quy. Có, để viết chương trình hiệu quả Trong các ngôn ngữ chức năng, đệ quy đuôi phải được sử dụng để tránh tràn ngăn xếp.

Danh sách

Một trong những yếu tố quan trọng nhất của lập trình hàm, cùng với đệ quy, là danh sách. Chúng cung cấp cơ sở cho các cấu trúc dữ liệu phức tạp. Giống như các ngôn ngữ chức năng khác, danh sách được liên kết đơn giản theo kiểu từ đầu đến cuối. Hàm cons được sử dụng để tạo một danh sách, còn các hàm car và cdr được sử dụng để truy cập tương ứng vào phần đầu và phần cuối của danh sách. Vì vậy, danh sách (danh sách 1 2 3) không gì khác hơn (khuyết điểm 1 (khuyết điểm 2 (khuyết điểm 3 "()))). Ở đây "() là một danh sách trống. Vì vậy, một hàm xử lý danh sách điển hình trông như thế này:

(xác định (tổng lst) (if (null? lst) 0 (+ (car lst) (sum (cdr lst)))))

Hàm này chỉ đơn giản tính tổng các phần tử của một danh sách. Đây là giao diện của nhiều hàm xử lý danh sách, một trong số chúng bài viết tiếp theo Tôi sẽ cho bạn biết lý do tại sao. Bây giờ, tôi chỉ lưu ý rằng nếu chúng ta thay thế đối số đầu tiên bằng 1, chúng ta sẽ nhận được một hàm tính độ dài của danh sách.

Hàm bậc cao hơn

Vì các hàm có thể được truyền dưới dạng đối số và được trả về dưới dạng giá trị, nên sẽ rất tốt nếu bạn tìm thấy cách sử dụng hàm này. Hãy xem xét ví dụ kinh điển sau:

(xác định (map f lst) (if (null? lst) lst (khuyết điểm (f (car lst)) (map f (cdr lst)))))

Hàm bản đồ áp dụng hàm f cho từng phần tử của danh sách. Nghe có vẻ lạ lùng nhưng giờ đây chúng ta có thể biểu thị hàm tính độ dài của độ dài danh sách theo tổng và bản đồ:

(xác định (độ dài lst) (tổng (bản đồ (lambda (x) 1) lst)))

Nếu bây giờ bạn đột nhiên quyết định rằng tất cả những điều này quá đơn giản, thì hãy nghĩ về điều này: làm thế nào để triển khai danh sách bằng cách sử dụng các hàm bậc cao hơn?

Nghĩa là, bạn cần triển khai các hàm cons , car và cdr sao cho chúng thỏa mãn mối quan hệ sau: đối với bất kỳ danh sách lst nào, giá trị (cons (car lst) (cdr lst)) trùng với lst là đúng. Điều này có thể được thực hiện như sau:

(xác định (khuyết điểm x xs) (lambda (pick) (if (eq? pick 1) x xs))) (xác định (car f) (f 1)) (xác định (cdr f) (f 2))

Làm thế nào nó hoạt động? Ở đây, hàm cons trả về một hàm khác có một tham số và tùy thuộc vào tham số đó sẽ trả về đối số thứ nhất hoặc thứ hai. Thật dễ dàng để kiểm tra xem mối quan hệ cần thiết có đúng với các hàm này hay không.

Sử dụng trích dẫn và lập trình meta

Một tính năng hay của ngôn ngữ Lisp là nó cực kỳ thuận tiện cho việc viết chương trình chuyển đổi các chương trình khác. Thực tế là một chương trình bao gồm các danh sách và danh sách là cấu trúc dữ liệu chính trong ngôn ngữ. Có một cách đơn giản là “trích dẫn” văn bản của một chương trình để nó được coi là một danh sách các nguyên tử.

nguyên tử chỉ đơn giản là các biểu thức ký tự, ví dụ ("hello "world) , giống như "(hello world) hoặc ở dạng đầy đủ (quote (hello world)) . Mặc dù hầu hết các phương ngữ Lisp đều có chuỗi , đôi khi bạn có thể hiểu được kèm theo trích dẫn. Quan trọng hơn, bằng cách sử dụng phương pháp này, bạn có thể đơn giản hóa việc tạo mã và xử lý chương trình.

Đầu tiên, chúng ta hãy cố gắng hiểu các phép tính tượng trưng. Điều này thường được hiểu là các hệ thống đại số máy tính có khả năng xử lý các đối tượng ký hiệu, công thức, phương trình và các đối tượng toán học phức tạp khác (có rất nhiều hệ thống như vậy, ví dụ chính là các hệ thống Cây phongToán học).

Bạn có thể thử thực hiện sự khác biệt mang tính biểu tượng. Tôi nghĩ tất cả những ai sắp học xong đều có thể tưởng tượng ra quy tắc vi phân (mặc dù trong thực tế mọi thứ phức tạp hơn một chút - ở đây chúng ta sẽ tính đạo hàm riêng bằng cách đếm các biến là hằng số, nhưng điều này không làm phức tạp vấn đề chút nào).

Vì vậy, tôi sẽ chỉ đưa ra một ví dụ về mã thể hiện bản chất của vấn đề, để lại chi tiết cho người đọc (tôi hy vọng những người sẽ nghiên cứu kỹ cuốn sách "Cấu trúc và diễn giải các chương trình máy tính").

(define (deriv exp var) (cond ((number? exp) 0) ((variable? exp) (if (cùng biến? exp var) 1 0)) ((sum? exp) (make-sum (deriv ( addend exp) var) (deriv (augend exp) var))) ((product? exp) (make-sum (make-product (multiplier exp) (deriv (multiplicand exp) var)) (make-product (deriv (multiplier) exp) var) (nhân exp)))) (khác (lỗi "loại biểu thức không xác định - DERIV" exp))))

Ở đây hàm phái sinh là sự triển khai của thuật toán vi phân như được dạy ở trường. Hàm này yêu cầu thực hiện các hàm số?. ,Biến đổi? v.v., điều này cho phép chúng ta hiểu bản chất của yếu tố biểu hiện này hay yếu tố kia. Bạn cũng cần phải thực hiện chức năng bổ sung sản phẩm tạo nên và tổng hợp. Ở đây chúng tôi sử dụng điều kiện xây dựng mà chúng tôi vẫn chưa biết - đây là một cách tương tự câu lệnh chuyển đổi trong các ngôn ngữ lập trình như C và Java.

Trước khi chúng ta chuyển sang triển khai các tính năng còn thiếu, điều đáng chú ý là lập trình chức năng thường sử dụng phương pháp phát triển từ trên xuống. Đây là khi các hàm tổng quát nhất được viết trước, sau đó là các hàm nhỏ chịu trách nhiệm triển khai chi tiết.

(xác định (biến? x) (ký hiệu? x)) (xác định (cùng biến? v1 v2) (và (biến? v1) (biến? v2) (eq? v1 v2))) (xác định (tổng hợp a1 a2) (danh sách "+ a1 a2)) (xác định (sản phẩm tạo ra m1 m2) (danh sách "* m1 m2)) (xác định (tổng? x) (và (cặp? x) (eq? (xe x) "+ ))) (xác định (thêm s) (cadr s)) (xác định (tăng thêm s) (caddr s)) (xác định (sản phẩm? x) (và (cặp? x) (eq? (xe x) "*)) ) (xác định (nhân p) (cadr p)) (xác định (nhân và p) (caddr p))

Việc thực hiện các hàm này không yêu cầu các chú thích đặc biệt, ngoại trừ các hàm cadr và caddr. Đây không gì khác ngoài các hàm trả về phần tử thứ hai và thứ ba của danh sách tương ứng.

Nếu sử dụng trình thông dịch Lược đồ tương tác, bạn có thể dễ dàng xác minh rằng mã kết quả hoạt động chính xác nhưng không đơn giản hóa các biểu thức:

(đạo hàm "(+ x 3) "x) => (+ 1 0) (đạo hàm "(* (* x y) (+ x 3)) "x) => (+ (* (* x y) (+ 1 0 )) (* (+ (* x 0) (* 1 y)) (+ x 3)))

Đối với những trường hợp tầm thường (ví dụ nhân với 0), bài toán đơn giản hóa được giải quyết khá dễ dàng. Câu hỏi này xin dành cho người đọc. Hầu hết các ví dụ trong bài viết này đều được lấy từ sách SICP nên nếu gặp khó khăn gì bạn chỉ cần tham khảo nguồn (sách này thuộc phạm vi công cộng).

Giống như bất kỳ phương ngữ nào, Lisp có khả năng lập trình siêu dữ liệu tuyệt vời, chủ yếu liên quan đến việc sử dụng macro. Thật không may, vấn đề này cần được phân tích trong một bài viết riêng.

Hãy viết một hàm sẽ loại bỏ đường cú pháp khỏi định nghĩa hàm như đã thảo luận trước đó:

(define (desugar-define def) (let ((fn-args (cadr def)) (body (caddr def))) (let ((name (car fn-args)) (args (cdr fn-args))) (danh sách "xác định tên (danh sách" lambda args body)))))

Hàm này hoạt động hiệu quả với các định nghĩa hàm được định dạng đúng:

(desugar-define "(define (succ x) (+ x 1))) => (define succ (lambda (x) (+ x 1)))

Tuy nhiên, điều này không hoạt động đối với các định nghĩa thông thường như (define x 5) .
Nếu chúng ta muốn loại bỏ đường cú pháp trong chương trình lớn chứa nhiều định nghĩa khác nhau thì chúng ta phải thực hiện kiểm tra bổ sung:

(xác định (có đường? def) (và (eq? (car def) "xác định) (danh sách? (cadr def))))

Việc kiểm tra như vậy có thể được tích hợp trực tiếp vào hàm khử đường, đảm bảo rằng nếu định nghĩa không cần loại bỏ đường cú pháp thì nó sẽ không thay đổi (bài tập tầm thường này dành cho người đọc). Sau đó, bạn có thể gói toàn bộ chương trình vào một danh sách và sử dụng bản đồ:

(bản đồ desugar-xác định prog)

Phần kết luận

Trong bài viết này, tôi không đặt cho mình nhiệm vụ nói về Đề án một cách chi tiết. Trước hết, tôi muốn chỉ ra một số tính năng thú vị của ngôn ngữ và thu hút người đọc nghiên cứu lập trình hàm. Ngôn ngữ tuyệt vời này, mặc dù đơn giản nhưng có sức hấp dẫn và tính năng riêng khiến việc lập trình bằng nó trở nên rất thú vị. Đối với công cụ làm việc với Đề án, những người có ý chí mạnh mẽ có thể sử dụng Đề án MIT, và phần còn lại - tận hưởng một môi trường học tập tuyệt vời Tiến sĩ vợt. Trong một trong những bài viết sau đây, tôi chắc chắn sẽ cho bạn biết cách viết trình thông dịch Đề án của riêng bạn.

Các chức năng là sự trừu tượng, trong đó chi tiết triển khai của một số hành động được ẩn đằng sau một tên riêng. Một bộ chức năng được viết tốt cho phép chúng được sử dụng nhiều lần. Thư viện chuẩn Python đi kèm với vô số hàm được xây dựng sẵn và gỡ lỗi, nhiều hàm trong số đó đủ linh hoạt để xử lý nhiều loại đầu vào khác nhau. Ngay cả khi một đoạn mã nhất định không được sử dụng nhiều lần, nhưng về mặt dữ liệu đầu vào và đầu ra, nó khá tự chủ, có thể tách nó thành một chức năng riêng biệt một cách an toàn.

Bài giảng này thiên về những cân nhắc thực tế hơn là lý thuyết lập trình chức năng. Tuy nhiên, khi cần thiết, các thuật ngữ liên quan sẽ được sử dụng và giải thích.

Tiếp theo, chúng ta sẽ xem xét chi tiết mô tả và cách sử dụng các hàm trong Python, đệ quy, các hàm truyền và trả về dưới dạng tham số, xử lý chuỗi và các trình vòng lặp, cũng như khái niệm về trình tạo. Sẽ chứng minh rằng trong Python, hàm là đối tượng (và do đó có thể được truyền dưới dạng tham số và được trả về khi thực thi hàm). Ngoài ra, chúng tôi sẽ nói về cách bạn có thể triển khai một số cơ chế lập trình hàm không hỗ trợ cú pháp trực tiếp trong Python nhưng phổ biến trong các ngôn ngữ lập trình hàm.

Lập trình chức năng là gì?

Lập trình chức năng là một phong cách lập trình chỉ sử dụng thành phần của chức năng. Nói cách khác, điều này lập trình trong các biểu thức hơn là các lệnh mệnh lệnh.

Như David Mertz đã chỉ ra trong bài viết về lập trình hàm trong Python, "chức năng lập trình - lập trình trong các ngôn ngữ chức năng (LISP, ML, OCAML, Haskell, ...)", các thuộc tính chính của chúng là:

  • “Sự hiện diện của các hàm hạng nhất” (các hàm, giống như các đối tượng khác, có thể được chuyển bên trong các hàm).
  • Đệ quy là cấu trúc điều khiển chính trong một chương trình.
  • Danh sách xử lý (trình tự).
  • Cấm các tác dụng phụ trong các hàm, chủ yếu có nghĩa là không có sự phân công (trong các ngôn ngữ hàm “thuần túy”)
  • Các toán tử bị cấm và nhấn mạnh vào biểu thức. Thay vì sử dụng các toán tử, toàn bộ chương trình lý tưởng nhất là một biểu thức có các định nghĩa đi kèm.
  • Câu hỏi then chốt: Cái gì cần phải tính toán, không Làm sao.
  • Sử dụng hàm bậc cao hơn (hàm trên hàm trên hàm).

Chương trình chức năng

Trong toán học, hàm số hiển thị các đối tượng từ cùng một bộ ( bộ định nghĩa hàm) đến cái khác ( tập hợp các giá trị hàm). Các hàm toán học (chúng được gọi là lau dọn) “một cách máy móc”, họ tính toán rõ ràng kết quả từ các đối số đã cho. Các hàm thuần túy không được lưu trữ bất kỳ dữ liệu nào giữa hai cuộc gọi. Bạn có thể coi chúng như những chiếc hộp đen, trong đó bạn chỉ biết chúng làm gì chứ không biết làm thế nào.

Các chương trình phong cách chức năng được thiết kế như thành phần chức năng. Trong trường hợp này, các hàm được hiểu theo cách gần giống như trong toán học: chúng ánh xạ đối tượng này sang đối tượng khác. Trong lập trình, các hàm “thuần túy” là một ý tưởng lý tưởng nhưng không phải lúc nào cũng có thể đạt được trong thực tế. thực tế các tính năng hữu ích thường có tác dụng phụ: Lưu trạng thái giữa các cuộc gọi hoặc thay đổi trạng thái của các đối tượng khác. Ví dụ, không thể tưởng tượng được các chức năng I/O mà không có tác dụng phụ. Trên thực tế, những chức năng như vậy được sử dụng vì những “hiệu ứng” này. Ngoài ra, các hàm toán học dễ dàng làm việc với các đối tượng yêu cầu lượng thông tin vô hạn (ví dụ: số thực). Nói chung, máy tính