Apa pola untuk antarmuka yang aman di C ++


22

Catatan: berikut ini adalah kode C ++ 03, tetapi kami mengharapkan perpindahan ke C ++ 11 dalam dua tahun ke depan, jadi kami harus mengingatnya.

Saya menulis panduan (untuk pemula, antara lain) tentang cara menulis antarmuka abstrak di C ++. Saya telah membaca kedua artikel Sutter tentang masalah ini, mencari di internet untuk contoh dan jawaban, dan membuat beberapa tes.

Kode ini TIDAK boleh dikompilasi!

void foo(SomeInterface & a, SomeInterface & b)
{
   SomeInterface c ;               // must not be default-constructible
   SomeInterface d(a);             // must not be copy-constructible
   a = b ;                         // must not be assignable
}

Semua perilaku di atas menemukan sumber masalah mereka dalam mengiris : Antarmuka abstrak (atau kelas non-daun dalam hierarki) tidak boleh dibangun atau dapat diopi / ditugaskan, BAHKAN jika kelas turunannya bisa.

Solusi 0: antarmuka dasar

class VirtuallyDestructible
{
   public :
      virtual ~VirtuallyDestructible() {}
} ;

Solusi ini sederhana, dan agak naif: Ini gagal semua kendala kami: Ini dapat dibangun secara default, salin-dibangun dan salin-ditugaskan (saya bahkan tidak yakin tentang memindahkan konstruktor dan tugas, tetapi saya masih memiliki 2 tahun untuk mencari keluar).

  1. Kami tidak dapat mendeklarasikan destructor pure virtual karena kami harus tetap inline, dan beberapa kompiler kami tidak akan mencerna metode virtual murni dengan inline empty body.
  2. Ya, satu-satunya poin dari kelas ini adalah membuat implementator benar-benar dapat dirusak, yang merupakan kasus yang jarang terjadi.
  3. Bahkan jika kita memiliki metode virtual murni tambahan (yang merupakan mayoritas kasus), kelas ini akan tetap dapat ditugaskan copy.

Jadi tidak ...

Solusi 1: boost :: noncopyable

class VirtuallyDestructible : boost::noncopyable
{
   public :
      virtual ~VirtuallyDestructible() {}
} ;

Solusi ini adalah yang terbaik, karena jelas, jelas, dan C ++ (tidak ada makro)

Masalahnya adalah bahwa itu masih tidak berfungsi untuk antarmuka spesifik itu karena VirtuallyConstructible masih dapat dibangun secara default .

  1. Kami tidak dapat mendeklarasikan virtual destructor murni karena kami harus menjaganya tetap inline, dan beberapa kompiler kami tidak akan mencernanya.
  2. Ya, satu-satunya poin dari kelas ini adalah membuat implementator benar-benar dapat dirusak, yang merupakan kasus yang jarang terjadi.

Masalah lain adalah bahwa kelas yang mengimplementasikan antarmuka yang tidak dapat disalin kemudian harus secara eksplisit mendeklarasikan / mendefinisikan copy constructor dan operator penugasan jika mereka perlu memiliki metode tersebut (dan dalam kode kami, kami memiliki kelas nilai yang masih dapat diakses oleh klien kami melalui antarmuka).

Ini bertentangan dengan Aturan Nol, yang merupakan tujuan yang ingin kita tuju: Jika implementasi default ok, maka kita harus dapat menggunakannya.

Solusi 2: buat mereka terlindungi!

class MyInterface
{
   public :
      virtual ~MyInterface() {}

   protected :
      // With C++11, these methods would be "= default"
      MyInterface() {}
      MyInterface(const MyInterface & ) {}
      MyInterface & operator = (const MyInterface & ) { return *this ; }
} ;

Pola ini mengikuti kendala teknis yang kami miliki (setidaknya dalam kode pengguna): MyInterface tidak dapat dibangun secara default, tidak dapat dibuat-salin, dan tidak dapat ditugaskan-salin.

Selain itu, tidak ada batasan buatan untuk mengimplementasikan kelas , yang kemudian bebas untuk mengikuti Aturan Nol, atau bahkan mendeklarasikan beberapa konstruktor / operator sebagai "= default" di C ++ 11/14 tanpa masalah.

Sekarang, ini sangat bertele-tele, dan sebuah alternatif akan menggunakan makro, sesuatu seperti:

class MyInterface
{
   public :
      virtual ~MyInterface() {}

   protected :
      DECLARE_AS_NON_SLICEABLE(MyInterface) ;
} ;

Yang dilindungi harus tetap berada di luar makro (karena tidak memiliki cakupan).

Benar "namespaced" (yaitu, diawali dengan nama perusahaan atau produk Anda), makro seharusnya tidak berbahaya.

Dan keuntungannya adalah bahwa kode tersebut diperhitungkan dalam satu sumber, alih-alih disalin dari semua antarmuka. Jika move-constructor dan move-assignment secara eksplisit dinonaktifkan dengan cara yang sama di masa depan, ini akan menjadi perubahan yang sangat ringan dalam kode.

Kesimpulan

  • Apakah saya paranoid ingin kode dilindungi dari pemotongan di antarmuka? (Aku yakin bukan, tapi tidak ada yang tahu ...)
  • Apa solusi terbaik di antara yang di atas?
  • Apakah ada solusi lain yang lebih baik?

Harap diingat bahwa ini adalah pola yang akan berfungsi sebagai pedoman bagi pemula (antara lain), jadi solusi seperti: "Setiap kasus harus memiliki implementasinya" bukan solusi yang layak.

Hadiah dan hasil

Saya memberikan hadiah untuk coredump karena waktu yang dihabiskan untuk menjawab pertanyaan, dan relevansi dari jawaban.

Solusi saya untuk masalah mungkin akan pergi ke sesuatu seperti itu:

class MyInterface
{
   DECLARE_CLASS_AS_INTERFACE(MyInterface) ;

   public :
      // the virtual methods
} ;

... dengan makro berikut:

#define DECLARE_CLASS_AS_INTERFACE(ClassName)                                \
   public :                                                                  \
      virtual ~ClassName() {}                                                \
   protected :                                                               \
      ClassName() {}                                                         \
      ClassName(const ClassName & ) {}                                       \
      ClassName & operator = (const ClassName & ) { return *this ; }         \
   private :

Ini adalah solusi yang layak untuk masalah saya karena alasan berikut:

  • Kelas ini tidak dapat dipakai (konstruktor dilindungi)
  • Kelas ini dapat dihancurkan secara virtual
  • Kelas ini dapat diwarisi tanpa memaksakan batasan yang tidak semestinya pada kelas-kelas yang diwarisi (mis. Kelas yang diwariskan secara default dapat disalin)
  • Penggunaan makro berarti antarmuka "deklarasi" mudah dikenali (dan dapat dicari), dan kodenya diperhitungkan di satu tempat sehingga lebih mudah untuk dimodifikasi (nama yang diawali dengan tepat akan menghapus bentrokan nama yang tidak diinginkan)

Perhatikan bahwa jawaban lain memberi wawasan berharga. Terima kasih untuk semua yang telah mencobanya.

Perhatikan bahwa saya kira saya masih bisa memberi hadiah lain untuk pertanyaan ini, dan saya menghargai jawaban yang cukup mencerahkan yang harus saya lihat, saya akan membuka hadiah hanya untuk memberikannya pada jawaban itu.


5
Tidak bisakah Anda menggunakan fungsi virtual murni di antarmuka? virtual void bar() = 0;sebagai contoh? Itu akan mencegah antarmuka Anda dari instanciated.
Morwenn

@Morwenn: Seperti yang dikatakan dalam pertanyaan, itu akan menyelesaikan 99% kasus (saya menargetkan 100% jika memungkinkan). Bahkan jika kita memilih untuk mengabaikan 1% yang hilang, itu juga tidak akan menyelesaikan pemotongan tugas. Jadi, tidak, ini bukan solusi yang baik.
paercebal

@Morwenn: Serius? ... :-D ... Saya pertama kali menulis pertanyaan ini di StackOverflow, dan kemudian berubah pikiran sebelum mengirimkannya. Apakah Anda yakin saya harus menghapusnya di sini, dan mengirimkannya ke SO?
paercebal

Jika saya benar, yang Anda butuhkan hanyalah virtual ~VirtuallyDestructible() = 0warisan virtual dari kelas antarmuka (hanya dengan anggota abstrak). Anda mungkin menghilangkan VirtuallyDestructible, mungkin.
Dieter Lücking

5
pa erc erc pa pa on virtual: Jika kompiler tersedak kelas virtual murni maka ia termasuk ke dalam tempat sampah Antarmuka yang sebenarnya adalah definisi virtual murni.
Tidak seorang pun

Jawaban:


13

Cara kanonik untuk membuat antarmuka dalam C ++ adalah dengan memberikannya destruktor virtual murni. Ini memastikan hal itu

  • Tidak ada instance dari kelas antarmuka itu sendiri dapat dibuat, karena C ++ tidak memungkinkan Anda untuk membuat instance dari kelas abstrak. Ini menangani persyaratan yang tidak dapat dibangun (standar dan salin).
  • Memanggil deletepointer ke antarmuka melakukan hal yang benar: ia memanggil destruktor dari kelas yang paling diturunkan untuk contoh itu.

hanya memiliki destruktor virtual murni tidak mencegah penugasan pada referensi ke antarmuka. Jika Anda ingin itu gagal juga, maka Anda harus menambahkan operator penugasan yang dilindungi ke antarmuka Anda.

Setiap kompiler C ++ harus dapat menangani kelas / antarmuka seperti ini (semua dalam satu file header):

class MyInterface {
public:
  virtual ~MyInterface() = 0;
protected:
  MyInterface& operator=(const MyInterface&) { return *this; } // or = default for C++14
};

inline MyInterface::~MyInterface() {}

Jika Anda memiliki kompiler yang tersedak ini (yang artinya harus pra-C ++ 98), maka opsi Anda 2 (memiliki konstruktor yang dilindungi) adalah yang terbaik kedua.

Penggunaan boost::noncopyabletidak disarankan untuk tugas ini, karena mengirimkan pesan bahwa semua kelas dalam hierarki harus tidak dapat disalin dan dengan demikian dapat membuat kebingungan bagi pengembang yang lebih berpengalaman yang tidak akan terbiasa dengan niat Anda untuk menggunakannya seperti ini.


If you need [prevent assignment] to fail as well, then you must add a protected assignment operator to your interface.: Ini adalah akar masalah saya. Kasus-kasus di mana saya memerlukan antarmuka untuk mendukung tugas pasti jarang. Di sisi lain, kasus-kasus di mana saya ingin melewatkan antarmuka dengan referensi (kasus-kasus di mana NULL tidak dapat diterima), dan dengan demikian, ingin menghindari no-op atau slicing yang mengkompilasi jauh lebih besar.
paercebal

Karena operator penugasan tidak boleh dipanggil, mengapa Anda memberikan definisi? Selain itu, mengapa tidak berhasil private? Selain itu, Anda mungkin ingin berurusan dengan default dan copy-ctor.
Deduplicator

5

Apakah saya paranoid ...

  • Apakah saya paranoid ingin kode dilindungi dari pemotongan di antarmuka? (Aku yakin bukan, tapi tidak ada yang tahu ...)

Bukankah ini masalah manajemen risiko?

  • apakah Anda takut bug yang terkait dengan pengirisan kemungkinan akan diperkenalkan?
  • apakah Anda pikir itu bisa tanpa disadari dan memprovokasi bug yang tidak dapat dipulihkan?
  • sejauh mana Anda bersedia pergi untuk menghindari mengiris?

Solusi terbaik

  • Apa solusi terbaik di antara yang di atas?

Solusi kedua Anda ("buat mereka terlindungi") terlihat bagus, tetapi ingatlah bahwa saya bukan ahli C ++.
Setidaknya, penggunaan yang tidak valid tampaknya benar dilaporkan sebagai salah oleh kompiler saya (g ++).

Sekarang, apakah Anda membutuhkan makro? Saya akan mengatakan "ya", karena meskipun Anda tidak mengatakan apa tujuan dari pedoman yang Anda tulis, saya kira ini untuk menegakkan serangkaian praktik terbaik tertentu dalam kode produk Anda.

Untuk tujuan itu, makro dapat membantu mendeteksi ketika orang secara efektif menerapkan pola: filter dasar dari commit dapat memberi tahu Anda apakah makro digunakan:

  • jika digunakan, maka polanya kemungkinan akan diterapkan, dan yang lebih penting, diterapkan dengan benar (cukup periksa apakah ada protectedkata kunci),
  • jika tidak digunakan, Anda dapat mencoba menyelidiki mengapa tidak.

Tanpa makro, Anda harus memeriksa apakah pola itu perlu dan diterapkan dengan baik dalam semua kasus.

Solusi yang lebih baik

  • Apakah ada solusi lain yang lebih baik?

Mengiris dalam C ++ tidak lebih dari kekhasan bahasa. Karena Anda sedang menulis panduan (khususnya untuk pemula), Anda harus fokus pada pengajaran dan tidak hanya menyebutkan "aturan aturan". Anda harus memastikan bahwa Anda benar-benar menjelaskan bagaimana dan mengapa pemotongan terjadi, bersama dengan contoh dan latihan (jangan menemukan kembali roda, dapatkan inspirasi dari buku dan tutorial).

Misalnya, judul latihan dapat berupa " Apa pola antarmuka aman di C ++ ?"

Jadi, langkah terbaik Anda adalah memastikan bahwa pengembang C ++ Anda memahami apa yang terjadi ketika pemotongan terjadi. Saya yakin bahwa jika mereka melakukannya, mereka tidak akan membuat banyak kesalahan dalam kode seperti yang Anda takuti, bahkan tanpa secara resmi menegakkan pola tertentu itu (tetapi Anda masih bisa menegakkannya, peringatan kompiler baik).

Tentang kompiler

Kamu bilang :

Saya tidak memiliki kekuatan dalam memilih kompiler untuk produk ini,

Seringkali orang akan berkata "Saya tidak punya hak untuk melakukan [X]" , "Saya tidak seharusnya melakukan [Y] ..." , ... karena mereka pikir ini tidak mungkin, dan bukan karena mereka mencoba atau bertanya.

Mungkin bagian dari deskripsi pekerjaan Anda untuk memberikan pendapat Anda tentang masalah teknis; jika Anda benar-benar berpikir kompiler adalah pilihan yang sempurna (atau unik) untuk domain masalah Anda, maka gunakan itu. Tetapi Anda juga mengatakan "destruktor virtual murni dengan implementasi inline bukan titik tersedak terburuk yang pernah saya lihat" ; dari pemahaman saya, kompiler itu begitu istimewa sehingga bahkan pengembang C ++ yang berpengetahuan luas pun kesulitan menggunakannya: kompiler lawas / in-house Anda sekarang merupakan hutang teknis, dan Anda memiliki hak (tugas?) untuk membahas masalah itu dengan pengembang dan manajer lain .

Cobalah untuk menilai biaya menjaga kompiler vs biaya menggunakan yang lain:

  1. Apa yang dikompilasi oleh kompiler saat ini yang tidak dapat dilakukan oleh yang lain?
  2. Apakah kode produk Anda mudah dikompilasi menggunakan kompiler lain? Mengapa tidak ?

Saya tidak tahu situasi Anda, dan sebenarnya Anda mungkin memiliki alasan yang sah untuk dikaitkan dengan kompiler tertentu.
Tetapi dalam kasus ini hanya inersia biasa, situasinya tidak akan pernah berevolusi jika Anda atau rekan kerja Anda tidak melaporkan produktivitas atau masalah utang teknis.


Am I paranoid...: "Jadikan antarmuka Anda mudah digunakan dengan benar, dan sulit digunakan secara tidak benar". Saya telah merasakan prinsip tertentu ketika seseorang melaporkan salah satu metode statis saya, secara tidak sengaja, digunakan secara salah. Kesalahan yang dihasilkan tampaknya tidak berhubungan, dan butuh beberapa jam seorang insinyur untuk menemukan sumbernya. "Kesalahan antarmuka" ini setara dengan menetapkan referensi antarmuka ke yang lain. Jadi, ya, saya ingin menghindari kesalahan semacam itu. Juga, dalam C ++, filosofi adalah untuk menangkap sebanyak mungkin pada waktu kompilasi, dan bahasa memberi kita kekuatan itu, jadi kita pergi dengannya.
paercebal

Best solution: Saya setuju. . . Better solution: Itu jawaban yang luar biasa. Saya akan mengerjakannya ... Sekarang, tentang Pure virtual classes: Apa ini? Antarmuka abstrak C ++? (kelas tanpa negara dan hanya metode virtual murni?). Bagaimana "kelas virtual murni" ini melindungi saya dari pemotongan? (Metode virtual murni akan membuat instantiation tidak dikompilasi, tetapi copy-assignment akan, dan memindahkan assignment akan, juga IIRC).
paercebal

About the compiler: Kami setuju, tetapi kompiler kami berada di luar ruang lingkup tanggung jawab saya (bukan berarti hal itu menghentikan saya dari komentar buruk ... :-p ...). Saya tidak akan mengungkapkan detailnya (saya berharap bisa) tetapi itu terkait dengan alasan internal (seperti ruang tes) dan alasan eksternal (mis. Klien yang terhubung dengan perpustakaan kami). Pada akhirnya, mengubah versi kompiler (atau bahkan menambalnya) BUKAN operasi sepele. Jangankan ganti satu kompiler yang rusak dengan gcc baru-baru ini.
paercebal

@ terima kasih terima kasih atas komentar Anda; tentang kelas virtual murni, Anda benar, itu tidak menyelesaikan semua kendala Anda (saya akan menghapus bagian ini). Saya memahami bagian "antarmuka kesalahan" dan bagaimana menangkap kesalahan pada waktu kompilasi berguna: tetapi Anda bertanya apakah Anda paranoid, dan saya pikir pendekatan rasionalnya adalah menyeimbangkan kebutuhan Anda untuk pemeriksaan statis dengan kemungkinan kesalahan terjadi. Semoga berhasil dengan kompilernya :)
coredump

1
Saya bukan penggemar makro, terutama karena pedoman ini ditargetkan (juga) di junior-men. Terlalu sering, saya telah melihat orang-orang yang diberi alat yang "berguna" untuk menerapkannya secara membabi buta dan tidak pernah mengerti apa yang sebenarnya terjadi. Mereka mulai percaya bahwa apa yang dilakukan makro pastilah hal yang paling rumit karena bos mereka berpikir itu akan terlalu sulit bagi mereka untuk melakukannya sendiri. Dan karena makro hanya ada di perusahaan Anda, mereka bahkan tidak dapat melakukan pencarian web untuk itu sedangkan untuk pedoman yang terdokumentasi apa fungsi anggota untuk menyatakan dan mengapa, mereka bisa.
5gon12eder

2

Masalah mengiris adalah satu, tetapi tentu saja bukan satu-satunya, yang diperkenalkan saat Anda mengekspos antarmuka polimorfik run-time kepada pengguna Anda. Pikirkan pointer nol, manajemen memori, data bersama. Tidak satu pun dari ini mudah diselesaikan dalam semua kasus (smart pointer bagus, tetapi bahkan mereka bukan peluru perak). Faktanya, dari pos Anda sepertinya Anda tidak mencoba menyelesaikan masalah pengirisan, tetapi mengesampingkannya dengan tidak mengizinkan pengguna untuk membuat salinan. Yang perlu Anda lakukan untuk menawarkan solusi untuk masalah mengiris, adalah menambahkan fungsi anggota klon virtual. Saya pikir masalah yang lebih dalam dengan mengekspos antarmuka polimorfik run-time adalah bahwa Anda memaksa pengguna untuk berurusan dengan semantik referensi, yang lebih sulit untuk dipikirkan daripada nilai semantik.

Cara terbaik yang saya tahu untuk menghindari masalah ini di C ++ adalah dengan menggunakan tipe erasure . Ini adalah teknik di mana Anda menyembunyikan antarmuka polimorfik run-time, di belakang antarmuka kelas normal. Antarmuka kelas normal ini kemudian memiliki semantik nilai dan menangani semua 'kekacauan' polimorfik di belakang layar. std::functionadalah contoh utama dari penghapusan tipe.

Untuk penjelasan yang bagus tentang mengapa mengekspos warisan kepada pengguna Anda buruk dan bagaimana penghapusan tipe dapat membantu memperbaikinya yang melihat presentasi ini oleh Sean Parent:

Inheritance Is The Base Class of Evil (versi singkat)

Nilai Semantik dan Polimorfisme Berbasis Konsep (versi panjang; lebih mudah diikuti, tetapi suaranya tidak bagus)


0

Anda tidak paranoid. Tugas profesional pertama saya sebagai seorang programmer C ++ menghasilkan irisan dan crash. Saya tahu orang lain. Tidak banyak solusi bagus untuk ini.

Mengingat kendala kompiler Anda, opsi 2 adalah yang terbaik. Alih-alih membuat makro, yang akan dilihat oleh programmer baru Anda sebagai aneh dan misterius, saya akan menyarankan skrip atau alat untuk menghasilkan kode secara otomatis. Jika karyawan baru Anda akan menggunakan IDE, Anda harus dapat membuat alat "New MYCOMPANY Interface" yang akan meminta nama antarmuka, dan membuat struktur yang Anda cari.

Jika programmer Anda menggunakan baris perintah, maka gunakan bahasa skrip apa pun yang tersedia bagi Anda untuk membuat skrip NewMyCompanyInterface untuk menghasilkan kode.

Saya telah menggunakan pendekatan ini di masa lalu untuk pola kode umum (antarmuka, mesin negara, dll). Bagian yang bagus adalah bahwa pemrogram baru dapat membaca output dan memahaminya dengan mudah, mereproduksi kode yang diperlukan ketika mereka membutuhkan sesuatu yang tidak dapat dihasilkan.

Makro dan pendekatan meta-pemrograman lainnya cenderung mengaburkan apa yang terjadi, dan programmer baru tidak belajar apa yang terjadi 'di balik tirai'. Ketika mereka harus mematahkan polanya, mereka sama tersesatnya seperti sebelumnya.

Dengan menggunakan situs kami, Anda mengakui telah membaca dan memahami Kebijakan Cookie dan Kebijakan Privasi kami.
Licensed under cc by-sa 3.0 with attribution required.