Mentransmisikan penunjuk fungsi ke tipe lain

89

Katakanlah saya memiliki sebuah fungsi yang menerima sebuah void (*)(void*)function pointer untuk digunakan sebagai callback:

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

Sekarang, jika saya memiliki fungsi seperti ini:

void my_callback_function(struct my_struct* arg);

Bisakah saya melakukan ini dengan aman?

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

Saya telah melihat pertanyaan ini dan saya telah melihat beberapa standar C yang mengatakan Anda dapat mentransmisikan ke 'penunjuk fungsi yang kompatibel', tetapi saya tidak dapat menemukan definisi tentang apa arti 'penunjuk fungsi yang kompatibel'.

Mike Weller
sumber
1
Saya agak pemula tapi apa artinya "void ( ) (void ) function pointer" ?. Apakah ini penunjuk ke fungsi yang menerima void * sebagai argumen dan mengembalikan void
Digital Gal
2
@Myke: void (*func)(void *)artinya funcadalah penunjuk ke fungsi dengan tanda tangan tipe seperti void foo(void *arg). Jadi ya, kamu benar.
mk12

Jawaban:

122

Sejauh menyangkut standar C, jika Anda mentransmisikan penunjuk fungsi ke penunjuk fungsi dari tipe yang berbeda dan kemudian memanggilnya, itu adalah perilaku yang tidak ditentukan . Lihat Lampiran J.2 (informatif):

Perilaku tersebut tidak ditentukan dalam situasi berikut:

  • Sebuah pointer digunakan untuk memanggil fungsi yang tipenya tidak kompatibel dengan tipe point-to (6.3.2.3).

Bagian 6.3.2.3, paragraf 8 berbunyi:

Sebuah penunjuk ke fungsi dari satu jenis dapat diubah menjadi penunjuk ke fungsi jenis lain dan kembali lagi; hasilnya akan dibandingkan dengan penunjuk asli. Jika penunjuk yang dikonversi digunakan untuk memanggil fungsi yang tipenya tidak kompatibel dengan tipe menunjuk-ke, perilakunya tidak ditentukan.

Jadi dengan kata lain, Anda dapat mentransmisikan penunjuk fungsi ke jenis penunjuk fungsi yang berbeda, melemparkannya kembali, dan memanggilnya, dan semuanya akan berfungsi.

Definisi kompatibel agak rumit. Ini dapat ditemukan di bagian 6.7.5.3, paragraf 15:

Untuk dua jenis fungsi agar kompatibel, keduanya harus menetapkan jenis kembalian yang kompatibel 127 .

Selain itu, daftar tipe parameter, jika keduanya ada, harus sesuai dalam jumlah parameter dan dalam penggunaan terminator elipsis; parameter yang sesuai harus memiliki tipe yang kompatibel. Jika satu tipe memiliki daftar tipe parameter dan tipe lainnya ditentukan oleh deklarator fungsi yang bukan merupakan bagian dari definisi fungsi dan berisi daftar pengenal kosong, daftar parameter tidak boleh memiliki terminator elipsis dan tipe dari setiap parameter harus kompatibel dengan jenis yang dihasilkan dari penerapan promosi argumen default. Jika satu tipe memiliki daftar tipe parameter dan tipe lainnya ditentukan oleh definisi fungsi yang berisi daftar pengenal (mungkin kosong), keduanya harus setuju dalam jumlah parameter, dan tipe dari setiap parameter prototipe harus kompatibel dengan tipe yang dihasilkan dari penerapan promosi argumen default ke tipe pengenal yang sesuai. (Dalam penentuan kompatibilitas tipe dan tipe komposit, setiap parameter yang dideklarasikan dengan fungsi atau tipe array dianggap memiliki tipe yang disesuaikan dan setiap parameter yang dideklarasikan dengan tipe yang memenuhi syarat dianggap memiliki versi yang tidak memenuhi syarat dari tipe yang dideklarasikan.)

127) Jika kedua jenis fungsi adalah '' gaya lama '', jenis parameter tidak dibandingkan.

Aturan untuk menentukan apakah dua jenis kompatibel dijelaskan di bagian 6.2.7, dan saya tidak akan mengutipnya di sini karena agak panjang, tetapi Anda dapat membacanya di draf standar C99 (PDF) .

Aturan yang relevan di sini ada di bagian 6.7.5.1, paragraf 2:

Untuk dua tipe penunjuk agar kompatibel, keduanya harus identik memenuhi syarat dan keduanya harus menjadi penunjuk ke jenis yang kompatibel.

Oleh karena itu, karena a void* tidak kompatibel dengan a struct my_struct*, penunjuk fungsi bertipe void (*)(void*)tidak kompatibel dengan penunjuk fungsi jenis void (*)(struct my_struct*), jadi casting penunjuk fungsi ini secara teknis merupakan perilaku yang tidak terdefinisi.

Dalam praktiknya, Anda dapat dengan aman menggunakan pointer fungsi casting dalam beberapa kasus. Dalam konvensi pemanggilan x86, argumen didorong pada stack, dan semua pointer memiliki ukuran yang sama (4 byte dalam x86 atau 8 byte dalam x86_64). Memanggil pointer fungsi bermuara pada mendorong argumen pada stack dan melakukan lompatan tidak langsung ke target pointer fungsi, dan jelas tidak ada gagasan tentang tipe pada level kode mesin.

Hal-hal yang pasti tidak bisa Anda lakukan:

  • Transmisikan di antara pointer fungsi dari konvensi panggilan yang berbeda. Anda akan mengacaukan tumpukan dan yang terbaik, crash, paling buruk, berhasil diam-diam dengan lubang keamanan besar yang menganga. Dalam pemrograman Windows, Anda sering melewatkan petunjuk fungsi. Win32 mengharapkan semua fungsi callback menggunakan stdcallkonvensi pemanggilan (yang macro CALLBACK, PASCALdan WINAPIsemua memperluas untuk). Jika Anda meneruskan penunjuk fungsi yang menggunakan konvensi panggilan C standar ( cdecl), hal buruk akan terjadi.
  • Dalam C ++, lakukan cast antara pointer fungsi anggota kelas dan pointer fungsi reguler. Ini sering membuat pemula C ++ tersandung. Fungsi anggota kelas memiliki thisparameter tersembunyi , dan jika Anda mentransmisikan fungsi anggota ke fungsi biasa, tidak ada thisobjek untuk digunakan, dan sekali lagi, banyak kejahatan akan terjadi.

Ide buruk lainnya yang terkadang berhasil tetapi juga merupakan perilaku yang tidak terdefinisi:

  • Mentransmisikan antara penunjuk fungsi dan penunjuk biasa (mis. Mentransmisikan a void (*)(void)ke a void*). Pointer fungsi belum tentu berukuran sama dengan pointer biasa, karena pada beberapa arsitektur mungkin terdapat informasi kontekstual tambahan. Ini mungkin akan bekerja dengan baik pada x86, tetapi ingatlah bahwa ini adalah perilaku yang tidak ditentukan.
Adam Rosenfield
sumber
18
Bukankah intinya void*adalah bahwa mereka kompatibel dengan penunjuk lain? Seharusnya tidak ada masalah mentransmisikan a struct my_struct*ke void*, pada kenyataannya Anda bahkan tidak perlu melakukan transmisi, kompilator harus menerimanya. Misalnya, jika Anda meneruskan a struct my_struct*ke fungsi yang membutuhkan void*, tidak perlu casting. Apa yang saya lewatkan di sini yang membuat ini tidak kompatibel?
Brianmearns
2
Jawaban ini mengacu pada "Ini mungkin akan bekerja dengan baik di x86 ...": Apakah ada platform di mana ini TIDAK akan berfungsi? Apakah ada yang punya pengalaman saat ini gagal? qsort () untuk C sepertinya tempat yang bagus untuk mentransmisikan penunjuk fungsi jika memungkinkan.
kevinarpe
4
@KCArpe: Menurut bagan di bawah tajuk "Penerapan Pointer Fungsi Anggota" di artikel ini , kompilator OpenWatcom 16-bit terkadang menggunakan jenis penunjuk fungsi yang lebih besar (4 byte) daripada jenis penunjuk data (2 byte) dalam konfigurasi tertentu. Namun, sistem yang sesuai dengan POSIX harus menggunakan representasi yang sama void*seperti untuk jenis penunjuk fungsi, lihat spesifikasi .
Adam Rosenfield
3
Tautan dari @adam sekarang mengacu pada standar POSIX edisi 2016 di mana bagian 2.12.3 yang relevan telah dihapus. Anda masih bisa menemukannya di edisi 2008 .
Martin Trenkmann
6
@brianmearns Tidak, void *hanya "kompatibel dengan" penunjuk (non-fungsi) lain dengan cara yang didefinisikan dengan sangat tepat (yang tidak terkait dengan apa yang dimaksud standar C dengan kata "kompatibel" dalam kasus ini). C memungkinkan a void *menjadi lebih besar atau lebih kecil dari a struct my_struct *, atau memiliki bit dalam urutan yang berbeda atau dinegasikan atau apa pun. Jadi void f(void *)dan void f(struct my_struct *)bisa jadi tidak kompatibel dengan ABI . C akan mengonversi pointer itu sendiri untuk Anda jika diperlukan, tetapi C tidak akan dan terkadang tidak dapat mengubah fungsi menunjuk ke untuk mengambil jenis argumen yang mungkin berbeda.
mtraceur
32

Saya bertanya tentang masalah yang sama persis mengenai beberapa kode di GLib baru-baru ini. (GLib adalah pustaka inti untuk proyek GNOME dan ditulis dalam C.) Saya diberitahu bahwa seluruh kerangka kerja sinyal slot bergantung padanya.

Di seluruh kode, ada banyak contoh casting dari tipe (1) hingga (2):

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

Biasanya melakukan chain-thru dengan panggilan seperti ini:

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;
}

Lihat sendiri di sini di g_array_sort(): http://git.gnome.org/browse/glib/tree/glib/garray.c

Jawaban di atas rinci dan kemungkinan besar benar - jika Anda duduk di komite standar. Adam dan Johannes berhak mendapatkan pujian atas tanggapan mereka yang telah diteliti dengan baik. Namun, di alam liar, Anda akan menemukan kode ini berfungsi dengan baik. Kontroversial? Iya. Pertimbangkan ini: GLib mengkompilasi / bekerja / menguji pada sejumlah besar platform (Linux / Solaris / Windows / OS X) dengan berbagai macam kompiler / penaut / pemuat kernel (GCC / CLang / MSVC). Standar terkutuk, kurasa.

Saya menghabiskan beberapa waktu untuk memikirkan jawaban-jawaban ini. Inilah kesimpulan saya:

  1. Jika Anda menulis pustaka panggilan balik, ini mungkin OK. Caveat emptor - gunakan dengan resiko Anda sendiri.
  2. Lain, jangan lakukan itu.

Berpikir lebih dalam setelah menulis tanggapan ini, saya tidak akan terkejut jika kode untuk kompiler C menggunakan trik yang sama. Dan karena (hampir semua?) Kompiler C modern di-bootstrap, ini berarti triknya aman.

Pertanyaan yang lebih penting untuk diteliti: Dapatkah seseorang menemukan platform / compiler / linker / loader di mana trik ini tidak berfungsi? Poin brownies utama untuk yang satu itu. Saya yakin ada beberapa prosesor / sistem tertanam yang tidak menyukainya. Namun, untuk komputasi desktop (dan mungkin seluler / tablet), trik ini mungkin masih berfungsi.

kevinarpe
sumber
10
Tempat di mana itu pasti tidak berfungsi adalah compiler Emscripten LLVM ke Javascript. Lihat github.com/kripken/emscripten/wiki/Asm-pointer-casts untuk detailnya.
Ben Lings
2
Referensi yang diperbarui tentang Emscripten .
ysdx
4
Tautan yang diposting @BenLings akan rusak dalam waktu dekat. Ini telah secara resmi dipindahkan ke kripken.github.io/emscripten-site/docs/porting/guidelines/…
Alex Reinking
9

Intinya bukanlah apakah Anda bisa. Solusi sepele adalah

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);

Kompiler yang baik hanya akan menghasilkan kode untuk my_callback_helper jika benar-benar dibutuhkan, dalam hal ini Anda akan senang melakukannya.

MSalters
sumber
Masalahnya, ini bukanlah solusi umum. Ini perlu dilakukan atas dasar kasus per kasus dengan pengetahuan tentang fungsinya. Jika Anda sudah memiliki fungsi jenis yang salah, Anda terjebak.
BeeOnRope
Semua kompiler yang saya uji dengan ini akan menghasilkan kode my_callback_helper, kecuali selalu inline. Ini jelas tidak perlu, karena satu-satunya hal yang cenderung dilakukannya adalah jmp my_callback_function. Kompilator mungkin ingin memastikan alamat untuk fungsi berbeda, tetapi sayangnya ia melakukan ini bahkan ketika fungsinya ditandai dengan C99 inline(yaitu "tidak peduli tentang alamatnya").
yyny
Saya tidak yakin ini benar. Komentar lain dari balasan lain di atas (oleh @mtraceur) mengatakan bahwa a void *bisa berukuran berbeda dari a struct *(saya pikir itu salah, karena jika malloctidak akan rusak, tetapi komentar itu memiliki 5 suara positif, jadi saya memberinya kredit. Jika @mtraceur benar, solusi yang Anda tulis tidak akan benar.
cesss
@cesss: Tidak masalah sama sekali jika ukurannya berbeda. Konversi ke dan dari void*masih harus berhasil. Singkatnya, void*mungkin memiliki lebih banyak bit, tetapi jika Anda mentransmisikan struct*ke void*bit ekstra tersebut dapat menjadi nol dan membuang kembali hanya dapat membuang nol itu lagi.
MSalters
@ MSalters: Saya benar-benar tidak tahu void *bisa (secara teori) begitu berbeda dari a struct *. Saya mengimplementasikan vtable di C, dan saya menggunakan thispointer C ++ - ish sebagai argumen pertama untuk fungsi virtual. Jelas, thisharus menjadi penunjuk ke struct "saat ini" (diturunkan). Jadi, fungsi virtual membutuhkan prototipe yang berbeda tergantung pada struct tempat mereka diimplementasikan. Saya pikir menggunakan void *thisargumen akan memperbaiki semuanya tapi sekarang saya belajar itu adalah perilaku yang tidak ditentukan ...
cesss
6

Anda memiliki tipe fungsi yang kompatibel jika tipe kembalian dan tipe parameternya kompatibel - pada dasarnya (kenyataannya lebih rumit :)). Kompatibilitas sama dengan "tipe yang sama" hanya saja lebih longgar untuk memungkinkan memiliki tipe yang berbeda tetapi masih memiliki beberapa bentuk yang mengatakan "tipe ini hampir sama". Di C89, misalnya, dua struct kompatibel jika keduanya identik tetapi hanya namanya yang berbeda. C99 tampaknya telah mengubah itu. Mengutip dari dokumen rasional (bacaan yang sangat dianjurkan, btw!):

Deklarasi struktur, gabungan, atau jenis enumerasi dalam dua unit terjemahan yang berbeda tidak secara resmi mendeklarasikan jenis yang sama, meskipun teks deklarasi ini berasal dari file include yang sama, karena unit terjemahan itu sendiri terpisah. Dengan demikian, Standar menetapkan aturan kompatibilitas tambahan untuk tipe tersebut, sehingga jika dua deklarasi tersebut cukup mirip, keduanya kompatibel.

Yang mengatakan - ya, ini benar-benar perilaku yang tidak terdefinisi, karena fungsi do_stuff Anda atau orang lain akan memanggil fungsi Anda dengan penunjuk fungsi yang memiliki void*parameter, tetapi fungsi Anda memiliki parameter yang tidak kompatibel. Namun demikian, saya mengharapkan semua kompiler untuk mengkompilasi dan menjalankannya tanpa mengeluh. Tetapi Anda dapat melakukan pembersihan dengan meminta fungsi lain mengambil void*(dan mendaftarkannya sebagai fungsi panggilan balik) yang kemudian akan memanggil fungsi Anda yang sebenarnya.

Johannes Schaub - litb
sumber
4

Karena kode C mengkompilasi ke instruksi yang sama sekali tidak peduli tentang jenis penunjuk, cukup baik untuk menggunakan kode yang Anda sebutkan. Anda akan mengalami masalah ketika Anda menjalankan do_stuff dengan fungsi callback dan penunjuk ke sesuatu yang lain lalu struktur my_struct sebagai argumen.

Saya harap saya bisa membuatnya lebih jelas dengan menunjukkan apa yang tidak berhasil:

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

atau...

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

Pada dasarnya, Anda dapat mentransmisikan petunjuk ke apa pun yang Anda suka, selama data tetap masuk akal pada saat proses dijalankan.

che
sumber
0

Jika Anda berpikir tentang cara kerja pemanggilan fungsi di C / C ++, pemanggilan fungsi tersebut mendorong item tertentu di tumpukan, melompat ke lokasi kode baru, mengeksekusi, lalu memunculkan tumpukan saat kembali. Jika pointer fungsi Anda mendeskripsikan fungsi dengan tipe kembalian yang sama dan jumlah / ukuran argumen yang sama, Anda akan baik-baik saja.

Jadi, saya pikir Anda harus bisa melakukannya dengan aman.

Steve Rowe
sumber
2
Anda hanya aman selama struct-pointers dan void-pointers memiliki representasi bit yang kompatibel; itu tidak dijamin akan terjadi
Christoph
1
Kompiler juga bisa meneruskan argumen dalam register. Dan bukan hal yang aneh untuk menggunakan register yang berbeda untuk float, int atau pointer.
MSalters
0

Void pointer kompatibel dengan jenis pointer lainnya. Ini adalah tulang punggung bagaimana malloc dan fungsi mem ( memcpy, memcmp) bekerja. Biasanya, di C (Bukan C ++) NULLmakro didefinisikan sebagai ((void *)0).

Lihat 6.3.2.3 (Item 1) di C99:

Sebuah pointer ke void dapat dikonversi ke atau dari pointer ke tipe objek atau tidak lengkap

xslogic.dll
sumber
Ini bertentangan dengan jawaban Adam Rosenfield , lihat paragraf terakhir dan komentar
pengguna
1
Jawaban ini jelas salah. Setiap penunjuk dapat diubah menjadi penunjuk kosong, kecuali penunjuk fungsi.
marton78