Con trỏ hàm, Closures và Lambda


86

Tôi hiện đang tìm hiểu về con trỏ hàm và khi tôi đang đọc chương K&R về chủ đề này, điều đầu tiên khiến tôi chú ý là, "Này, điều này giống như một sự kết thúc." Tôi biết rằng giả định này về cơ bản là sai bằng cách nào đó và sau khi tìm kiếm trực tuyến, tôi không thực sự tìm thấy bất kỳ phân tích nào về sự so sánh này.

Vậy tại sao con trỏ hàm kiểu C về cơ bản lại khác với bao đóng hoặc lambdas? Theo như tôi có thể nói, nó liên quan đến thực tế là con trỏ hàm vẫn trỏ đến một hàm đã xác định (được đặt tên) trái ngược với thực tế là xác định ẩn danh hàm.

Tại sao việc truyền một hàm cho một hàm được coi là mạnh hơn trong trường hợp thứ hai, khi nó không được đặt tên, so với trường hợp đầu tiên khi nó chỉ là một hàm bình thường, hàng ngày đang được truyền?

Xin vui lòng cho tôi biết làm thế nào và tại sao tôi sai khi so sánh hai quá chặt chẽ.

Cảm ơn.

Câu trả lời:


108

Một lambda (hoặc bao đóng ) đóng gói cả con trỏ hàm và các biến. Đây là lý do tại sao, trong C #, bạn có thể làm:

int lessThan = 100;
Func<int, bool> lessThanTest = delegate(int i) {
   return i < lessThan;
};

Tôi đã sử dụng một đại biểu ẩn danh ở đó như một bao đóng (cú pháp của nó rõ ràng hơn và gần với C hơn một chút so với tương đương lambda), điều này đã bắt lessThan (một biến ngăn xếp) vào bao đóng. Khi quá trình đóng được đánh giá, lessThan (có khung ngăn xếp có thể đã bị phá hủy) sẽ tiếp tục được tham chiếu. Nếu tôi thay đổi lessThan, thì tôi thay đổi so sánh:

int lessThan = 100;
Func<int, bool> lessThanTest = delegate(int i) {
   return i < lessThan;
};

lessThanTest(99); // returns true
lessThan = 10;
lessThanTest(99); // returns false

Trong C, điều này sẽ là bất hợp pháp:

BOOL (*lessThanTest)(int);
int lessThan = 100;

lessThanTest = &LessThan;

BOOL LessThan(int i) {
   return i < lessThan; // compile error - lessThan is not in scope
}

mặc dù tôi có thể xác định một con trỏ hàm có 2 đối số:

int lessThan = 100;
BOOL (*lessThanTest)(int, int);

lessThanTest = &LessThan;
lessThanTest(99, lessThan); // returns true
lessThan = 10;
lessThanTest(100, lessThan); // returns false

BOOL LessThan(int i, int lessThan) {
   return i < lessThan;
}

Nhưng, bây giờ tôi phải vượt qua 2 đối số khi tôi đánh giá nó. Nếu tôi muốn chuyển con trỏ hàm này sang một hàm khác mà lessThan không nằm trong phạm vi, tôi sẽ phải giữ nó tồn tại theo cách thủ công bằng cách chuyển nó cho từng hàm trong chuỗi hoặc bằng cách quảng bá nó ra toàn cầu.

Mặc dù hầu hết các ngôn ngữ chính thống hỗ trợ bao đóng đều sử dụng các hàm ẩn danh, nhưng không có yêu cầu nào cho điều đó. Bạn có thể có các bao đóng mà không có hàm ẩn danh và các hàm ẩn danh không có bao đóng.

Tóm tắt: một bao đóng là sự kết hợp của con trỏ hàm + các biến được bắt.


cảm ơn, bạn đã thực sự đưa ý tưởng về nhà mà những người khác đang cố gắng đạt được.
Không có

Có thể bạn đang sử dụng phiên bản C cũ hơn khi viết điều này hoặc không nhớ chuyển tiếp khai báo hàm, nhưng tôi không quan sát thấy hành vi tương tự như bạn đã đề cập khi tôi kiểm tra điều này. Ideone.com/JsDVBK
smac89

@ smac89 - bạn đã biến biến lessThan trở thành toàn cục - tôi đã đề cập rõ ràng điều đó như một sự thay thế.
Mark Brackett

42

Là một người đã viết trình biên dịch cho các ngôn ngữ có và không có dấu đóng 'thực', tôi trân trọng không đồng ý với một số câu trả lời ở trên. Bao đóng Lisp, Scheme, ML hoặc Haskell không tự động tạo ra một hàm mới . Thay vào đó, nó sử dụng lại một hàm hiện có nhưng làm như vậy với các biến miễn phí mới . Tập hợp các biến tự do thường được gọi là môi trường , ít nhất là bởi các nhà lý thuyết ngôn ngữ lập trình.

Bao đóng chỉ là một tập hợp chứa một hàm và một môi trường. Trong trình biên dịch ML tiêu chuẩn của New Jersey, chúng tôi đại diện cho một bản ghi; một trường chứa một con trỏ đến mã và các trường khác chứa các giá trị của các biến tự do. Trình biên dịch đã tạo động một bao đóng mới (không phải hàm) bằng cách cấp phát một bản ghi mới có chứa một con trỏ đến cùng một mã, nhưng với các giá trị khác nhau cho các biến tự do.

Bạn có thể mô phỏng tất cả điều này trong C, nhưng nó là một nỗi đau trong ass. Hai kỹ thuật phổ biến:

  1. Chuyển một con trỏ đến hàm (mã) và một con trỏ riêng biệt đến các biến tự do, để đóng cửa được chia thành hai biến C.

  2. Chuyển một con trỏ đến một cấu trúc, trong đó cấu trúc chứa các giá trị của các biến tự do và cũng là một con trỏ đến mã.

Kỹ thuật số 1 là lý tưởng khi bạn đang cố gắng mô phỏng một số loại đa hình trong C và bạn không muốn tiết lộ loại môi trường --- bạn sử dụng con trỏ void * để đại diện cho môi trường. Ví dụ, hãy xem các Giao diện và Triển khai C của Dave Hanson . Kỹ thuật số 2, gần giống với những gì xảy ra trong trình biên dịch mã gốc cho các ngôn ngữ chức năng, cũng giống với một kỹ thuật quen thuộc khác ... các đối tượng C ++ với các hàm thành viên ảo. Việc triển khai gần như giống hệt nhau.

Quan sát này dẫn đến một suy nghĩ khôn ngoan từ Henry Baker:

Mọi người trong thế giới Algol / Fortran đã phàn nàn trong nhiều năm rằng họ không hiểu những gì có thể sử dụng đóng hàm sẽ có trong lập trình hiệu quả trong tương lai. Sau đó, cuộc cách mạng về 'lập trình định hướng đối tượng' đã xảy ra, và bây giờ tất cả mọi người đều lập trình sử dụng hàm đóng, ngoại trừ việc họ vẫn từ chối gọi chúng như vậy.


1
+1 để giải thích và trích dẫn rằng OOP thực sự đang đóng - sử dụng lại một hàm hiện có nhưng làm như vậy với các biến miễn phí mới - các hàm (phương thức) sử dụng môi trường (một con trỏ cấu trúc đến dữ liệu đối tượng không có gì khác ngoài trạng thái mới) để hoạt động.
Legends2k

8

Trong C, bạn không thể định nghĩa hàm nội tuyến, vì vậy bạn không thể thực sự tạo một bao đóng. Tất cả những gì bạn đang làm là chuyển một tham chiếu đến một số phương thức được xác định trước. Trong các ngôn ngữ hỗ trợ các phương thức / bao đóng ẩn danh, định nghĩa của các phương thức linh hoạt hơn rất nhiều.

Nói một cách đơn giản nhất, con trỏ hàm không có phạm vi được liên kết với chúng (trừ khi bạn tính phạm vi toàn cục), trong khi các bao đóng bao gồm phạm vi của phương thức xác định chúng. Với lambdas, bạn có thể viết một phương thức viết một phương thức. Closures cho phép bạn liên kết "một số đối số với một hàm và kết quả là nhận được một hàm có độ hiếm thấp hơn." (trích từ bình luận của Thomas). Bạn không thể làm điều đó trong C.

CHỈNH SỬA: Thêm một ví dụ (Tôi sẽ sử dụng cú pháp Actionscript-ish vì đó là điều tôi nghĩ ngay bây giờ):

Giả sử bạn có một số phương thức lấy một phương thức khác làm đối số của nó, nhưng không cung cấp cách truyền bất kỳ tham số nào cho phương thức đó khi nó được gọi? Chẳng hạn như, một phương thức nào đó gây ra độ trễ trước khi chạy phương thức bạn đã truyền nó (ví dụ ngu ngốc, nhưng tôi muốn giữ cho nó đơn giản).

function runLater(f:Function):Void {
  sleep(100);
  f();
}

Bây giờ giả sử bạn muốn người dùng runLater () để trì hoãn một số quá trình xử lý một đối tượng:

function objectProcessor(o:Object):Void {
  /* Do something cool with the object! */
}

function process(o:Object):Void {
  runLater(function() { objectProcessor(o); });
}

Hàm bạn đang chuyển đến process () không còn là một hàm được định nghĩa tĩnh nữa. Nó được tạo động và có thể bao gồm các tham chiếu đến các biến có trong phạm vi khi phương thức được xác định. Vì vậy, nó có thể truy cập 'o' và 'objectProcessor', mặc dù chúng không thuộc phạm vi toàn cầu.

Tôi hy vọng rằng ý thức thực hiện.


Tôi đã điều chỉnh câu trả lời của mình dựa trên nhận xét của bạn. Tôi vẫn chưa rõ ràng 100% về các chi tiết cụ thể của các điều khoản, vì vậy tôi chỉ trích dẫn trực tiếp bạn. :)
Herms 16/10/08

Khả năng nội tuyến của các hàm ẩn danh là chi tiết triển khai của (hầu hết?) Các ngôn ngữ lập trình chính thống - nó không phải là yêu cầu đối với các bao đóng.
Mark Brackett 16/10/08

6

Đóng cửa = logic + môi trường.

Ví dụ, hãy xem xét phương pháp C # 3 này:

public Person FindPerson(IEnumerable<Person> people, string name)
{
    return people.Where(person => person.Name == name);
}

Biểu thức lambda không chỉ đóng gói logic ("so sánh tên") mà còn cả môi trường, bao gồm tham số (tức là biến cục bộ) "tên".

Để biết thêm về điều này, hãy xem bài viết của tôi về cách đóng sẽ đưa bạn qua C # 1, 2 và 3, cho thấy cách đóng giúp mọi thứ dễ dàng hơn.


xem xét thay thế void bằng IEnumerable <Person>
Amy B

1
@David B: Chúc mừng, xong rồi. @edg: Tôi nghĩ nó không chỉ là trạng thái, bởi vì nó là trạng thái có thể thay đổi . Nói cách khác, nếu bạn thực hiện một bao đóng thay đổi một biến cục bộ (trong khi vẫn nằm trong phương thức) thì biến cục bộ đó cũng thay đổi theo. "Môi trường" dường như truyền đạt điều này tốt hơn với tôi, nhưng nó rất khó.
Jon Skeet

Tôi đánh giá cao câu trả lời nhưng điều đó thực sự không rõ ràng gì đối với tôi, có vẻ như mọi người chỉ là một đối tượng và bạn đang gọi một phương thức trên đó. Có lẽ chỉ là tôi không biết C #.
Không có

Đúng, nó đang gọi một phương thức trên đó - nhưng tham số mà nó truyền là giá trị đóng.
Jon Skeet

4

Trong C, con trỏ hàm có thể được truyền dưới dạng đối số cho hàm và trả về dưới dạng giá trị từ hàm, nhưng hàm chỉ tồn tại ở cấp cao nhất: bạn không thể lồng các định nghĩa hàm vào nhau. Hãy nghĩ xem sẽ cần những gì để C hỗ trợ các hàm lồng nhau có thể truy cập các biến của hàm bên ngoài, trong khi vẫn có thể gửi con trỏ hàm lên và xuống ngăn xếp cuộc gọi. (Để làm theo lời giải thích này, bạn nên biết những điều cơ bản về cách thực hiện các lệnh gọi hàm trong ngôn ngữ C và hầu hết các ngôn ngữ tương tự: duyệt qua mục ngăn xếp lệnh gọi trên Wikipedia.)

Loại đối tượng nào là một con trỏ đến một hàm lồng nhau? Nó không thể chỉ là địa chỉ của mã, bởi vì nếu bạn gọi nó, làm thế nào nó truy cập các biến của hàm bên ngoài? (Hãy nhớ rằng vì đệ quy, có thể có một số lệnh gọi khác nhau của hàm bên ngoài hoạt động cùng một lúc.) Đây được gọi là bài toán funarg và có hai bài toán con: bài toán funargs hướng xuống và bài toán funarg hướng lên.

Vấn đề funargs đi xuống, tức là, gửi một con trỏ hàm "xuống ngăn xếp" làm đối số cho một hàm bạn gọi, thực ra không tương thích với C và GCC hỗ trợ các hàm lồng nhau dưới dạng funargs đi xuống. Trong GCC, khi bạn tạo một con trỏ đến một hàm lồng nhau, bạn thực sự nhận được một con trỏ tới một tấm bạt lò xo , một đoạn mã được tạo động để thiết lập con trỏ liên kết tĩnh và sau đó gọi hàm thực, hàm này sử dụng con trỏ liên kết tĩnh để truy cập các biến của hàm ngoài.

Vấn đề funargs trở lên khó hơn. GCC không ngăn cản bạn để một con trỏ tấm bạt lò xo tồn tại sau khi chức năng bên ngoài không còn hoạt động nữa (không có bản ghi trên ngăn xếp cuộc gọi), và khi đó con trỏ liên kết tĩnh có thể trỏ tới rác. Bản ghi kích hoạt không còn có thể được phân bổ trên ngăn xếp. Giải pháp thông thường là phân bổ chúng trên heap và để một đối tượng hàm đại diện cho một hàm lồng nhau chỉ trỏ đến bản ghi kích hoạt của hàm bên ngoài. Một đối tượng như vậy được gọi là bao đóng . Sau đó, ngôn ngữ thường sẽ phải hỗ trợ thu gom rác để các bản ghi có thể được giải phóng khi không còn con trỏ trỏ đến chúng.

Lambdas ( hàm ẩn danh ) thực sự là một vấn đề riêng biệt, nhưng thông thường một ngôn ngữ cho phép bạn xác định các hàm ẩn danh một cách nhanh chóng cũng sẽ cho phép bạn trả về chúng dưới dạng giá trị hàm, vì vậy chúng sẽ bị đóng.


3

Lambda là một hàm ẩn danh, được xác định động. Bạn không thể làm điều đó trong C ... như đối với các đóng (hoặc kết tội của cả hai), ví dụ ngọng điển hình sẽ trông giống như sau:

(defun get-counter (n-start +-number)
     "Returns a function that returns a number incremented
      by +-number every time it is called"
    (lambda () (setf n-start (+ +-number n-start))))

Theo thuật ngữ C, bạn có thể nói rằng môi trường từ vựng (ngăn xếp) của get-counterđang được hàm ẩn danh nắm bắt và được sửa đổi nội bộ như ví dụ sau cho thấy:

[1]> (defun get-counter (n-start +-number)
         "Returns a function that returns a number incremented
          by +-number every time it is called"
        (lambda () (setf n-start (+ +-number n-start))))
GET-COUNTER
[2]> (defvar x (get-counter 2 3))
X
[3]> (funcall x)
5
[4]> (funcall x)
8
[5]> (funcall x)
11
[6]> (funcall x)
14
[7]> (funcall x)
17
[8]> (funcall x)
20
[9]> 

2

Closures ngụ ý một số biến từ điểm định nghĩa hàm được ràng buộc cùng với logic hàm, giống như có thể khai báo một đối tượng nhỏ một cách nhanh chóng.

Một vấn đề quan trọng với C và các bao đóng là các biến được phân bổ trên ngăn xếp sẽ bị hủy khi rời khỏi phạm vi hiện tại, bất kể bao đóng có trỏ đến chúng hay không. Điều này sẽ dẫn đến các loại lỗi mà mọi người mắc phải khi họ bất cẩn trả lại con trỏ cho các biến cục bộ. Đóng cửa về cơ bản ngụ ý tất cả các biến có liên quan là các mục được đếm lại hoặc được thu thập rác trên một đống.

Tôi không thoải mái khi đánh đồng lambda với bao đóng vì tôi không chắc rằng lambda trong tất cả các ngôn ngữ đều là bao đóng, đôi khi tôi nghĩ lambda chỉ được xác định cục bộ các hàm ẩn danh mà không có sự ràng buộc của các biến (Python trước 2.1?).


2

Trong GCC, có thể mô phỏng các hàm lambda bằng cách sử dụng macro sau:

#define lambda(l_ret_type, l_arguments, l_body)       \
({                                                    \
    l_ret_type l_anonymous_functions_name l_arguments \
    l_body                                            \
    &l_anonymous_functions_name;                      \
})

Ví dụ từ nguồn :

qsort (array, sizeof (array) / sizeof (array[0]), sizeof (array[0]),
     lambda (int, (const void *a, const void *b),
             {
               dump ();
               printf ("Comparison %d: %d and %d\n",
                       ++ comparison, *(const int *) a, *(const int *) b);
               return *(const int *) a - *(const int *) b;
             }));

Việc sử dụng kỹ thuật này tất nhiên sẽ loại bỏ khả năng ứng dụng của bạn hoạt động với các trình biên dịch khác và dường như là hành vi "không xác định" nên YMMV.


2

Việc đóng bắt các biến tự do trong một môi trường . Môi trường sẽ vẫn tồn tại, mặc dù mã xung quanh có thể không còn hoạt động.

Một ví dụ trong Common Lisp, nơi MAKE-ADDERtrả về một bao đóng mới.

CL-USER 53 > (defun make-adder (start delta) (lambda () (incf start delta)))
MAKE-ADDER

CL-USER 54 > (compile *)
MAKE-ADDER
NIL
NIL

Sử dụng chức năng trên:

CL-USER 55 > (let ((adder1 (make-adder 0 10))
                   (adder2 (make-adder 17 20)))
               (print (funcall adder1))
               (print (funcall adder1))
               (print (funcall adder1))
               (print (funcall adder1))
               (print (funcall adder2))
               (print (funcall adder2))
               (print (funcall adder2))
               (print (funcall adder1))
               (print (funcall adder1))
               (describe adder1)
               (describe adder2)
               (values))

10 
20 
30 
40 
37 
57 
77 
50 
60 
#<Closure 1 subfunction of MAKE-ADDER 4060001ED4> is a CLOSURE
Function         #<Function 1 subfunction of MAKE-ADDER 4060001CAC>
Environment      #(60 10)
#<Closure 1 subfunction of MAKE-ADDER 4060001EFC> is a CLOSURE
Function         #<Function 1 subfunction of MAKE-ADDER 4060001CAC>
Environment      #(77 20)

Lưu ý rằng DESCRIBEhàm cho thấy rằng các đối tượng hàm cho cả hai bao đóng đều giống nhau, nhưng môi trường khác nhau.

Common Lisp làm cho cả hai đối tượng hàm đóng và thuần túy (những đối tượng không có môi trường) đều là hàm và người ta có thể gọi cả hai theo cùng một cách, ở đây bằng cách sử dụng FUNCALL.


1

Sự khác biệt chính phát sinh từ việc thiếu phạm vi từ vựng trong C.

Một con trỏ hàm chỉ là một con trỏ đến một khối mã. Bất kỳ biến không ngăn xếp nào mà nó tham chiếu là toàn cục, tĩnh hoặc tương tự.

Đóng, OTOH, có trạng thái riêng của nó ở dạng 'biến bên ngoài', hoặc 'giá trị tăng'. chúng có thể ở chế độ riêng tư hoặc được chia sẻ như bạn muốn, sử dụng phạm vi từ vựng. Bạn có thể tạo nhiều bao đóng với cùng một mã hàm, nhưng các trường hợp biến khác nhau.

Một vài bao đóng có thể chia sẻ một số biến và do đó có thể là giao diện của một đối tượng (theo nghĩa OOP). để thực hiện điều đó trong C, bạn phải liên kết một cấu trúc với một bảng con trỏ hàm (đó là những gì C ++ thực hiện, với một vtable lớp).

trong ngắn hạn, một bao đóng là một con trỏ hàm PLUS một số trạng thái. đó là một cấu trúc cấp cao hơn


2
WTF? C chắc chắn có phạm vi từ vựng.
Luís Oliveira

1
nó có 'phạm vi tĩnh'. như tôi hiểu, phạm vi từ vựng là một tính năng phức tạp hơn để duy trì ngữ nghĩa tương tự trên một ngôn ngữ có các hàm được tạo động, sau đó được gọi là các hàm đóng.
Javier

1

Hầu hết các phản hồi chỉ ra rằng các bao đóng yêu cầu con trỏ hàm, có thể là các hàm ẩn danh, nhưng như Mark đã viết, các bao đóng có thể tồn tại với các hàm được đặt tên. Đây là một ví dụ trong Perl:

{
    my $count;
    sub increment { return $count++ }
}

Bao đóng là môi trường xác định $countbiến. Nó chỉ có sẵn cho incrementchương trình con và vẫn tồn tại giữa các lần gọi.


0

Trong C, một con trỏ hàm là một con trỏ sẽ gọi một hàm khi bạn tham chiếu đến nó, một bao đóng là một giá trị chứa logic của hàm và môi trường (các biến và giá trị mà chúng bị ràng buộc) và lambda thường tham chiếu đến một giá trị thực sự là một chức năng không được đặt tên. Trong C, một hàm không phải là giá trị lớp đầu tiên nên nó không thể được truyền xung quanh, vì vậy bạn phải chuyển một con trỏ tới nó, tuy nhiên trong các ngôn ngữ hàm (như Scheme), bạn có thể truyền các hàm theo cách bạn truyền bất kỳ giá trị nào khác

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.