Truyền một con trỏ hàm đến một kiểu khác


88

Giả sử tôi có một hàm chấp nhận một void (*)(void*)con trỏ hàm để sử dụng làm lệnh gọi lại:

void do_stuff(void (*callback_fp)(void*), void* callback_arg);

Bây giờ, nếu tôi có một chức năng như thế này:

void my_callback_function(struct my_struct* arg);

Tôi có thể làm điều này một cách an toàn?

do_stuff((void (*)(void*)) &my_callback_function, NULL);

Tôi đã xem xét câu hỏi này và tôi đã xem xét một số tiêu chuẩn C nói rằng bạn có thể truyền tới 'con trỏ chức năng tương thích', nhưng tôi không thể tìm thấy định nghĩa 'con trỏ chức năng tương thích' nghĩa là gì.


1
Tôi là một người mới nhưng "con trỏ hàm void ( ) (void )" nghĩa là gì ?. Nó là một con trỏ tới một hàm mà chấp nhận một void * như một tham số và trả về void
Digital Gal

2
@Myke: void (*func)(void *)có nghĩa funclà một con trỏ đến một hàm có chữ ký kiểu chẳng hạn void foo(void *arg). Vì vậy, vâng, bạn đã đúng.
mk12

Câu trả lời:


121

Theo như tiêu chuẩn C có liên quan, nếu bạn truyền một con trỏ hàm đến một con trỏ hàm thuộc kiểu khác và sau đó gọi nó, đó là hành vi không xác định . Xem Phụ lục J.2 (cung cấp thông tin):

Hành vi không được xác định trong các trường hợp sau:

  • Con trỏ được sử dụng để gọi một hàm có kiểu không tương thích với kiểu trỏ đến (6.3.2.3).

Phần 6.3.2.3, đoạn 8 đọc:

Một con trỏ đến một chức năng của một kiểu có thể được chuyển đổi thành một con trỏ đến một chức năng của một kiểu khác và quay lại; kết quả sẽ được so sánh bằng con trỏ ban đầu. Nếu một con trỏ được chuyển đổi được sử dụng để gọi một hàm có kiểu không tương thích với kiểu trỏ tới, hành vi đó là không xác định.

Vì vậy, nói cách khác, bạn có thể ép kiểu con trỏ hàm đến một kiểu con trỏ hàm khác, ép kiểu lại và gọi nó, và mọi thứ sẽ hoạt động.

Định nghĩa về tương thích hơi phức tạp. Nó có thể được tìm thấy trong mục 6.7.5.3, đoạn 15:

Để hai kiểu chức năng tương thích, cả hai đều phải chỉ định kiểu trả về tương thích 127 .

Hơn nữa, danh sách kiểu tham số, nếu cả hai đều có mặt, sẽ thống nhất về số lượng tham số và cách sử dụng dấu chấm lửng; các tham số tương ứng phải có kiểu tương thích. Nếu một kiểu có danh sách kiểu tham số và kiểu khác được chỉ định bởi trình khai báo hàm không phải là một phần của định nghĩa hàm và chứa danh sách định danh trống, danh sách tham số sẽ không có dấu chấm lửng và kiểu của mỗi tham số sẽ tương thích với loại kết quả từ việc áp dụng các quảng cáo đối số mặc định. Nếu một kiểu có danh sách kiểu tham số và kiểu khác được chỉ định bởi định nghĩa hàm có chứa danh sách định danh (có thể trống), thì cả hai sẽ đồng ý về số lượng tham số, và kiểu của mỗi tham số nguyên mẫu phải tương thích với kiểu là kết quả của việc áp dụng các quảng cáo đối số mặc định cho kiểu của số nhận dạng tương ứng. (Trong việc xác định tính tương thích của kiểu và kiểu kết hợp, mỗi tham số được khai báo với kiểu hàm hoặc kiểu mảng được coi là có kiểu điều chỉnh và mỗi tham số được khai báo với kiểu đủ điều kiện được coi là có phiên bản không đủ điều kiện của kiểu đã khai báo.)

127) Nếu cả hai kiểu hàm đều là '' kiểu cũ '', thì các kiểu tham số sẽ không được so sánh.

Các quy tắc để xác định xem hai loại có tương thích hay không được mô tả trong phần 6.2.7 và tôi sẽ không trích dẫn chúng ở đây vì chúng khá dài, nhưng bạn có thể đọc chúng trên bản nháp của tiêu chuẩn C99 (PDF) .

Quy tắc liên quan ở đây nằm trong mục 6.7.5.1, đoạn 2:

Để hai loại con trỏ tương thích, cả hai đều phải đủ tiêu chuẩn giống nhau và cả hai đều phải là con trỏ tới các loại tương thích.

Do đó, vì a void* không tương thích với a struct my_struct*, một loại con trỏ hàm void (*)(void*)không tương thích với một loại con trỏ hàm void (*)(struct my_struct*), do đó, việc đúc con trỏ hàm về mặt kỹ thuật là hành vi không xác định.

Tuy nhiên, trong thực tế, bạn có thể an toàn với việc truyền con trỏ hàm trong một số trường hợp. Trong quy ước gọi x86, các đối số được đẩy lên ngăn xếp và tất cả các con trỏ đều có cùng kích thước (4 byte trong x86 hoặc 8 byte trong x86_64). Việc gọi một con trỏ hàm dẫn đến việc đẩy các đối số trên ngăn xếp và thực hiện một bước nhảy gián tiếp đến mục tiêu con trỏ hàm và rõ ràng là không có khái niệm về các loại ở cấp mã máy.

Những điều bạn chắc chắn không thể làm:

  • Truyền giữa các con trỏ hàm của các quy ước gọi khác nhau. Bạn sẽ làm xáo trộn ngăn xếp và tệ nhất là sụp đổ, tệ nhất là thành công một cách âm thầm với một lỗ hổng bảo mật khổng lồ. Trong lập trình Windows, bạn thường chuyển các con trỏ hàm xung quanh. Win32 hy vọng tất cả các chức năng gọi lại để sử dụng các stdcallquy ước gọi (mà các macro CALLBACK, PASCALWINAPItất cả các mở rộng sang). Nếu bạn truyền một con trỏ hàm sử dụng quy ước gọi C chuẩn ( cdecl), thì lỗi sẽ dẫn đến.
  • Trong C ++, ép kiểu giữa con trỏ hàm thành viên lớp và con trỏ hàm thông thường. Điều này thường làm cho người mới học C ++. Các hàm thành viên của lớp có một thistham số ẩn , và nếu bạn chuyển một hàm thành viên thành một hàm thông thường, sẽ không có thisđối tượng nào để sử dụng và một lần nữa, nhiều lỗi sẽ dẫn đến.

Một ý tưởng tồi khác đôi khi có thể hoạt động nhưng cũng là hành vi không xác định:

  • Truyền giữa con trỏ hàm và con trỏ thông thường (ví dụ: ép kiểu a void (*)(void)thành a void*). Con trỏ hàm không nhất thiết phải có cùng kích thước với con trỏ thông thường, vì trên một số kiến ​​trúc, chúng có thể chứa thêm thông tin ngữ cảnh. Điều này có thể sẽ hoạt động tốt trên x86, nhưng hãy nhớ rằng đó là hành vi không xác định.

18
Không phải toàn bộ vấn đề void*là chúng tương thích với bất kỳ con trỏ nào khác? Sẽ không có vấn đề gì khi truyền từ a struct my_struct*đến a void*, trên thực tế bạn thậm chí không cần phải truyền, trình biên dịch chỉ nên chấp nhận nó. Ví dụ: nếu bạn chuyển một hàm struct my_struct*cho một hàm nhận void*, không cần truyền. Tôi đang thiếu điều gì ở đây khiến chúng không tương thích?
brianmearns

2
Câu trả lời này liên quan đến "Điều này có thể sẽ hoạt động tốt trên x86 ...": Có bất kỳ nền tảng nào mà điều này sẽ KHÔNG hoạt động không? Có ai có kinh nghiệm khi điều này không thành công? qsort () cho C có vẻ như là một nơi tuyệt vời để ép một con trỏ hàm nếu có thể.
kevinarpe

4
@KCArpe: Theo biểu đồ dưới tiêu đề "Triển khai con trỏ hàm thành viên" trong bài viết này , trình biên dịch OpenWatcom 16-bit đôi khi sử dụng loại con trỏ hàm lớn hơn (4 byte) so với loại con trỏ dữ liệu (2 byte) trong các cấu hình nhất định. Tuy nhiên, các hệ thống tuân theo POSIX phải sử dụng cùng một cách biểu diễn đối void*với các loại con trỏ hàm, hãy xem thông số kỹ thuật .
Adam Rosenfield

3
Liên kết từ @adam hiện đề cập đến phiên bản 2016 của tiêu chuẩn POSIX trong đó phần liên quan 2.12.3 đã bị xóa. Bạn vẫn có thể tìm thấy nó trong phiên bản 2008 .
Martin Trenkmann

6
@brianmearns Không, void *chỉ "tương thích với" bất kỳ con trỏ nào khác (không phải chức năng) theo những cách được xác định rất chính xác (không liên quan đến ý nghĩa của tiêu chuẩn C với từ "tương thích" trong trường hợp này). C cho phép a void *lớn hơn hoặc nhỏ hơn a struct my_struct *, hoặc có các bit theo thứ tự khác nhau hoặc bị phủ định hoặc bất cứ điều gì. Vì vậy void f(void *)void f(struct my_struct *)có thể không tương thích ABI . C sẽ tự chuyển đổi các con trỏ cho bạn nếu cần, nhưng nó sẽ không và đôi khi không thể chuyển đổi một hàm trỏ đến để có một kiểu đối số có thể khác.
mtraceur

32

Tôi đã hỏi về vấn đề tương tự chính xác này liên quan đến một số mã trong GLib gần đây. (GLib là một thư viện cốt lõi cho dự án GNOME và được viết bằng C.) Tôi đã được thông báo rằng toàn bộ khung ký hiệu của slot'n phụ thuộc vào nó.

Xuyên suốt mã, có rất nhiều trường hợp truyền từ kiểu (1) sang (2):

  1. typedef int (*CompareFunc) (const void *a, const void *b)
  2. typedef int (*CompareDataFunc) (const void *b, const void *b, void *user_data)

Thông thường chuỗi thông qua các cuộc gọi như thế này:

int stuff_equal (GStuff      *a,
                 GStuff      *b,
                 CompareFunc  compare_func)
{
    return stuff_equal_with_data(a, b, (CompareDataFunc) compare_func, NULL);
}

int stuff_equal_with_data (GStuff          *a,
                           GStuff          *b,
                           CompareDataFunc  compare_func,
                           void            *user_data)
{
    int result;
    /* do some work here */
    result = compare_func (data1, data2, user_data);
    return result;
}

Xem cho chính mình ở đây trong g_array_sort(): http://git.gnome.org/browse/glib/tree/glib/garray.c

Các câu trả lời trên rất chi tiết và có thể đúng - nếu bạn ngồi trong ủy ban tiêu chuẩn. Adam và Johannes xứng đáng được ghi nhận vì những câu trả lời được nghiên cứu kỹ lưỡng của họ. Tuy nhiên, ngoài tự nhiên, bạn sẽ thấy mã này hoạt động tốt. Gây tranh cãi? Đúng. Hãy xem xét điều này: GLib biên dịch / hoạt động / thử nghiệm trên một số lượng lớn nền tảng (Linux / Solaris / Windows / OS X) với nhiều trình biên dịch / liên kết / bộ nạp hạt nhân (GCC / CLang / MSVC). Tôi đoán là các tiêu chuẩn đã chết tiệt.

Tôi đã dành thời gian suy nghĩ về những câu trả lời này. Đây là kết luận của tôi:

  1. Nếu bạn đang viết thư viện gọi lại, điều này có thể ổn. Cảnh báo trước - sử dụng có rủi ro của riêng bạn.
  2. Khác, đừng làm điều đó.

Suy nghĩ sâu hơn sau khi viết phản hồi này, tôi sẽ không ngạc nhiên nếu mã cho trình biên dịch C sử dụng thủ thuật tương tự. Và vì (hầu hết / tất cả?) Các trình biên dịch C hiện đại đều được khởi động, điều này có nghĩa là thủ thuật là an toàn.

Một câu hỏi quan trọng hơn để nghiên cứu: Ai đó có thể tìm thấy một nền tảng / trình biên dịch / trình liên kết / trình tải ở nơi thủ thuật này không hoạt động không? Bánh brownie chính cho điểm đó. Tôi cá rằng có một số bộ xử lý / hệ thống nhúng không thích nó. Tuy nhiên, đối với máy tính để bàn (và có thể là điện thoại di động / máy tính bảng), thủ thuật này có thể vẫn hoạt động.


10
Một nơi mà nó chắc chắn không hoạt động là trình biên dịch Emscripten LLVM sang Javascript. Xem github.com/kripken/emscripten/wiki/Asm-pointer-casts để biết chi tiết.
Ben Lings

2
Tham khảo cập nhật về Emscripten .
ysdx

4
Liên kết @BenLings đăng lên sẽ đứt trong thời gian tới. Nó đã chính thức chuyển sang kripken.github.io/emscripten-site/docs/porting/guidelines/…
Alex Reinking vào

9

Vấn đề thực sự không phải là liệu bạn có thể. Giải pháp tầm thường là

void my_callback_function(struct my_struct* arg);
void my_callback_helper(void* pv)
{
    my_callback_function((struct my_struct*)pv);
}
do_stuff(&my_callback_helper);

Một trình biên dịch tốt sẽ chỉ tạo mã cho my_callback_helper nếu nó thực sự cần thiết, trong trường hợp đó, bạn sẽ rất vui vì nó đã làm được.


Vấn đề là đây không phải là một giải pháp chung. Nó cần được thực hiện theo từng trường hợp cụ thể với kiến ​​thức về chức năng. Nếu bạn đã có một chức năng không đúng loại, bạn bị mắc kẹt.
BeeOnRope

Tất cả các trình biên dịch mà tôi đã thử nghiệm điều này sẽ tạo ra mã my_callback_helper, trừ khi nó luôn được nội tuyến. Điều này chắc chắn là không cần thiết, vì điều duy nhất nó có xu hướng làm là jmp my_callback_function. Trình biên dịch có lẽ muốn đảm bảo địa chỉ cho các hàm là khác nhau, nhưng không may là nó thực hiện điều này ngay cả khi hàm được đánh dấu bằng C99 inline(tức là "không quan tâm đến địa chỉ").
yyny

Tôi không chắc điều này là chính xác. Một nhận xét khác từ một câu trả lời khác ở trên (của @mtraceur) nói rằng a void *có thể có kích thước khác với a struct *(Tôi nghĩ điều đó là sai, vì nếu không mallocsẽ bị hỏng, nhưng nhận xét đó có 5 lượt ủng hộ, vì vậy tôi sẽ ghi nhận nó. Nếu @mtraceur đúng, giải pháp bạn viết sẽ không đúng.
cesss

@cesss: Không quan trọng chút nào nếu kích thước khác nhau. Việc chuyển đổi sang và đi void*vẫn phải hoạt động. Nói tóm lại, void*có thể có nhiều bit hơn, nhưng nếu bạn ép kiểu a struct*đến void*những bit thừa đó có thể là số 0 và phép ép kiểu lại có thể loại bỏ các số 0 đó một lần nữa.
MSalters

@MSalters: Tôi thực sự không biết a void *có thể (về lý thuyết) có thể khác với a struct *. Tôi đang triển khai một vtable trong C và tôi đang sử dụng thiscon trỏ C ++ - ish làm đối số đầu tiên cho các hàm ảo. Rõ ràng, thisphải là một con trỏ đến cấu trúc "hiện tại" (dẫn xuất). Vì vậy, các hàm ảo cần các nguyên mẫu khác nhau tùy thuộc vào cấu trúc mà chúng được triển khai. Tôi đã nghĩ sử dụng một void *thisđối số sẽ khắc phục được mọi thứ nhưng bây giờ tôi biết được rằng đó là hành vi không xác định ...
cesss

6

Bạn có kiểu hàm tương thích nếu kiểu trả về và kiểu tham số tương thích - về cơ bản (thực tế phức tạp hơn :)). Khả năng tương thích cũng giống như "cùng loại" chỉ lỏng lẻo hơn khi cho phép có nhiều loại khác nhau nhưng vẫn có một số hình thức nói "các loại này gần như giống nhau". Ví dụ, trong C89, hai cấu trúc tương thích nếu chúng giống hệt nhau nhưng chỉ khác tên của chúng. C99 dường như đã thay đổi điều đó. Trích dẫn từ tài liệu lý do c (rất khuyến khích đọc, btw!):

Các khai báo kiểu cấu trúc, liên hợp hoặc kiểu liệt kê trong hai đơn vị dịch khác nhau không chính thức khai báo cùng một kiểu, ngay cả khi văn bản của các khai báo này đến từ cùng một tệp bao gồm, vì bản thân các đơn vị dịch là rời rạc. Do đó, Tiêu chuẩn quy định các quy tắc tương thích bổ sung cho các loại như vậy, để nếu hai khai báo như vậy đủ giống nhau thì chúng sẽ tương thích.

Điều đó nói - vâng, đây là hành vi không xác định, bởi vì hàm do_stuff của bạn hoặc người khác sẽ gọi hàm của bạn bằng một con trỏ hàm có void*tham số, nhưng hàm của bạn có một tham số không tương thích. Nhưng tuy nhiên, tôi hy vọng tất cả các trình biên dịch đều có thể biên dịch và chạy nó mà không phải than vãn. Nhưng bạn có thể làm sạch hơn bằng cách có một hàm khác sử dụng một void*(và đăng ký đó làm hàm gọi lại) mà sau đó sẽ gọi hàm thực của bạn.


4

Vì mã C biên dịch thành hướng dẫn không quan tâm chút nào đến các loại con trỏ, nên sử dụng mã bạn đề cập là khá tốt. Bạn sẽ gặp sự cố khi chạy do_stuff với hàm gọi lại của mình và con trỏ đến một thứ khác rồi đến cấu trúc my_struct làm đối số.

Tôi hy vọng tôi có thể làm cho nó rõ ràng hơn bằng cách hiển thị những gì sẽ không hoạt động:

int my_number = 14;
do_stuff((void (*)(void*)) &my_callback_function, &my_number);
// my_callback_function will try to access int as struct my_struct
// and go nuts

hoặc là...

void another_callback_function(struct my_struct* arg, int arg2) { something }
do_stuff((void (*)(void*)) &another_callback_function, NULL);
// another_callback_function will look for non-existing second argument
// on the stack and go nuts

Về cơ bản, bạn có thể truyền con trỏ tới bất kỳ thứ gì bạn thích, miễn là dữ liệu tiếp tục có ý nghĩa tại thời điểm chạy.


0

Nếu bạn nghĩ về cách hoạt động của các lệnh gọi hàm trong C / C ++, chúng đẩy một số mục nhất định lên ngăn xếp, nhảy đến vị trí mã mới, thực thi, sau đó trả lại ngăn xếp. Nếu con trỏ hàm của bạn mô tả các hàm có cùng kiểu trả về và cùng số lượng / kích thước đối số, bạn sẽ không sao.

Vì vậy, tôi nghĩ bạn sẽ có thể làm như vậy một cách an toàn.


2
bạn chỉ an toàn miễn là struct-pointers và void-pointers có các biểu diễn bit tương thích; điều đó không được đảm bảo là đúng như vậy
Christoph

1
Các trình biên dịch cũng có thể truyền các đối số trong thanh ghi. Và không phải là chưa từng sử dụng các thanh ghi khác nhau cho float, int hoặc pointer.
MSalters

0

Con trỏ rỗng tương thích với các loại con trỏ khác. Nó là xương sống của cách hoạt động của malloc và các hàm mem ( memcpy, memcmp). Thông thường, trong C (chứ không phải C ++) NULLlà một macro được định nghĩa là ((void *)0).

Xem 6.3.2.3 (Mục 1) trong C99:

Một con trỏ đến void có thể được chuyển đổi thành hoặc từ một con trỏ thành bất kỳ loại đối tượng hoặc không hoàn chỉnh nào


Điều này mâu thuẫn với câu trả lời của Adam Rosenfield , hãy xem đoạn cuối và nhận xét
người dùng

1
Câu trả lời này rõ ràng là sai. Mọi con trỏ đều có thể chuyển đổi thành con trỏ void, ngoại trừ con trỏ hàm.
marton78,
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.