Qual è il modello per un'interfaccia sicura in C ++


22

Nota: il seguente è il codice C ++ 03, ma prevediamo un passaggio a C ++ 11 nei prossimi due anni, quindi dobbiamo tenerlo presente.

Sto scrivendo una linea guida (per i neofiti, tra gli altri) su come scrivere un'interfaccia astratta in C ++. Ho letto entrambi gli articoli di Sutter sull'argomento, ho cercato su Internet esempi e risposte e ho fatto alcuni test.

Questo codice NON deve essere compilato!

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
}

Tutti i comportamenti sopra citati trovano l'origine del loro problema nell'affettare : l'interfaccia astratta (o la classe non foglia nella gerarchia) non dovrebbe essere costruibile né copiabile / assegnabile, ANCHE se la classe derivata può esserlo.

0a soluzione: l'interfaccia di base

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

Questa soluzione è semplice e in qualche modo ingenua: non riesce a superare tutti i nostri vincoli: può essere costruita per impostazione predefinita, costruita per copia e assegnata per copia (non sono nemmeno sicuro di spostare costruttori e assegnazione, ma ho ancora 2 anni per capire fuori).

  1. Non possiamo dichiarare il distruttore puro virtuale perché dobbiamo tenerlo in linea e alcuni dei nostri compilatori non digeriranno i metodi virtuali puri con il corpo vuoto in linea.
  2. Sì, l'unico punto di questa classe è rendere gli attuatori praticamente distruttibili, il che è un caso raro.
  3. Anche se avessimo un metodo puro virtuale aggiuntivo (che è la maggior parte dei casi), questa classe sarebbe comunque assegnabile alla copia.

Quindi no ...

1a soluzione: boost :: non copiabile

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

Questa soluzione è la migliore, perché è semplice, chiara e C ++ (senza macro)

Il problema è che non funziona ancora per quella specifica interfaccia perché VirtuallyConstructible può ancora essere costruito di default .

  1. Non possiamo dichiarare il distruttore puro virtuale perché dobbiamo tenerlo in linea e alcuni dei nostri compilatori non lo digeriranno.
  2. Sì, l'unico punto di questa classe è rendere gli attuatori praticamente distruttibili, il che è un caso raro.

Un altro problema è che le classi che implementano l'interfaccia non copiabile devono quindi dichiarare / definire esplicitamente il costruttore della copia e l'operatore di assegnazione se devono avere quei metodi (e nel nostro codice, abbiamo classi di valore a cui il nostro cliente può ancora accedere tramite interfacce).

Questo va contro la Regola di Zero, che è dove vogliamo andare: se l'implementazione di default è ok, dovremmo essere in grado di usarla.

2a soluzione: rendili protetti!

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

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

Questo modello segue i vincoli tecnici che avevamo (almeno nel codice utente): MyInterface non può essere costruito in modo predefinito, non può essere costruito in copia e non può essere assegnato alla copia.

Inoltre, non impone alcun vincolo artificiale all'implementazione delle classi , che sono quindi libere di seguire la Regola dello Zero, o addirittura dichiarare alcuni costruttori / operatori come "= default" in C ++ 11/14 senza problemi.

Ora, questo è abbastanza dettagliato, e un'alternativa sarebbe usare una macro, qualcosa del tipo:

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

   protected :
      DECLARE_AS_NON_SLICEABLE(MyInterface) ;
} ;

Il protetto deve rimanere all'esterno della macro (perché non ha ambito).

Correttamente "namespace" (ovvero, con il prefisso del nome dell'azienda o del prodotto), la macro dovrebbe essere innocua.

E il vantaggio è che il codice viene preso in considerazione in una fonte, anziché essere incollato in tutte le interfacce. Se il costruttore di mosse e l'assegnazione di mosse dovessero essere esplicitamente disabilitati allo stesso modo in futuro, si tratterebbe di un leggero cambiamento nel codice.

Conclusione

  • Sono paranoico per volere che il codice sia protetto contro lo slicing nelle interfacce? (Credo di no, ma non si sa mai ...)
  • Qual è la migliore soluzione tra quelle sopra?
  • C'è un'altra soluzione migliore?

Ricorda che questo è uno schema che servirà da guida per i neofiti (tra gli altri), quindi una soluzione come: "Ogni caso dovrebbe avere la sua implementazione" non è una soluzione praticabile.

Bounty e risultati

Ho assegnato la grazia a coredump a causa del tempo impiegato per rispondere alle domande e della pertinenza delle risposte.

La mia soluzione al problema probabilmente andrà a qualcosa del genere:

class MyInterface
{
   DECLARE_CLASS_AS_INTERFACE(MyInterface) ;

   public :
      // the virtual methods
} ;

... con la seguente macro:

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

Questa è una soluzione praticabile per il mio problema per i seguenti motivi:

  • Questa classe non può essere istanziata (i costruttori sono protetti)
  • Questa classe può essere praticamente distrutta
  • Questa classe può essere ereditata senza imporre vincoli indebiti sull'ereditarietà delle classi (ad esempio, la classe ereditaria potrebbe essere di default copiabile)
  • L'uso della macro significa che la "dichiarazione" dell'interfaccia è facilmente riconoscibile (e ricercabile), e il suo codice è preso in considerazione in un unico punto, facilitando la modifica (un nome opportunamente prefissato rimuoverà gli scontri indesiderabili)

Si noti che le altre risposte hanno fornito preziose informazioni. Grazie a tutti voi che ci avete provato.

Nota che credo di poter ancora dare un altro premio a questa domanda, e apprezzo abbastanza le risposte illuminanti che dovrei vederne una, vorrei aprire un premio solo per assegnarlo a quella risposta.


5
Non puoi semplicemente usare le funzioni virtuali pure nell'interfaccia? virtual void bar() = 0;per esempio? Ciò impedirebbe l'installazione della tua interfaccia.
Morwenn,

@Morwenn: Come detto nella domanda, ciò risolverebbe il 99% dei casi (mirare al 100% se possibile). Anche se scegliamo di ignorare l'1% mancante, non risolverebbe anche la suddivisione del compito. Quindi no, questa non è una buona soluzione.
paercebal,

@Morwenn: Seriamente? ... MrGreen ... Ho prima scritto questa domanda su StackOverflow, e poi ho cambiato idea poco prima di inviarlo. Credi che dovrei eliminarlo qui e inviarlo a SO?
paercebal,

Se ho ragione, tutto ciò che serve è virtual ~VirtuallyDestructible() = 0l'eredità virtuale delle classi di interfaccia (solo con membri astratti). Potresti omettere che Virtualmente Distruttibile, probabilmente.
Dieter Lücking,

5
@paercebal: se il compilatore soffoca su classi virtuali pure, allora appartiene al cestino. Una vera interfaccia è per definizione pura virtuale.
Nessuno l'

Risposte:


13

Il modo canonico di creare un'interfaccia in C ++ è di dargli un puro distruttore virtuale. Questo lo assicura

  • Non è possibile creare istanze della stessa classe di interfaccia, poiché C ++ non consente di creare un'istanza di una classe astratta. Questo si occupa dei requisiti non costruibili (sia di default che di copia).
  • Chiamare deleteun puntatore all'interfaccia fa la cosa giusta: chiama il distruttore della classe più derivata per quell'istanza.

il solo fatto di avere un puro distruttore virtuale non impedisce l'assegnazione di un riferimento all'interfaccia. Se hai bisogno che fallisca anche tu, allora devi aggiungere un operatore di assegnazione protetta alla tua interfaccia.

Qualsiasi compilatore C ++ dovrebbe essere in grado di gestire una classe / interfaccia come questa (tutto in un file di intestazione):

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

inline MyInterface::~MyInterface() {}

Se hai un compilatore che soffoca su questo (il che significa che deve essere pre-C ++ 98), allora l'opzione 2 (con costruttori protetti) è un ottimo secondo.

L'uso boost::noncopyablenon è consigliabile per questa attività, perché invia il messaggio che tutte le classi nella gerarchia dovrebbero essere non copiabili e può quindi creare confusione per gli sviluppatori più esperti che non avrebbero familiarità con le intenzioni di usarlo in questo modo.


If you need [prevent assignment] to fail as well, then you must add a protected assignment operator to your interface.: Questa è la radice del mio problema. I casi in cui ho bisogno di un'interfaccia per supportare l'assegnazione devono essere davvero rari. D'altra parte, i casi in cui voglio passare un'interfaccia per riferimento (i casi in cui NULL non è accettabile), e quindi, voglio evitare una no-op o il slicing di quella compilazione sono molto maggiori.
paercebal,

Dato che l'operatore di assegnazione non dovrebbe mai essere chiamato, perché gli dai una definizione? A parte questo, perché non farlo private? Inoltre, potresti voler trattare con default e copy-ctor.
Deduplicatore

5

Sono paranoico ...

  • Sono paranoico per volere che il codice sia protetto contro lo slicing nelle interfacce? (Credo di no, ma non si sa mai ...)

Non è questo un problema di gestione del rischio?

  • temi che sia probabile che venga introdotto un bug relativo all'affettatura?
  • pensi che possa passare inosservato e provocare bug irrecuperabili?
  • fino a che punto sei disposto ad andare per evitare il taglio?

Soluzione migliore

  • Qual è la migliore soluzione tra quelle sopra?

La tua seconda soluzione ("rendili protetti") sembra buona, ma tieni presente che non sono un esperto di C ++.
Almeno, gli usi non validi sembrano essere stati correttamente segnalati come errati dal mio compilatore (g ++).

Ora, hai bisogno di macro? Direi "sì", perché anche se non dici qual è lo scopo delle linee guida che stai scrivendo, suppongo che questo sia quello di far rispettare un particolare insieme di migliori pratiche nel codice del tuo prodotto.

A tale scopo, le macro possono aiutare a rilevare quando le persone applicano efficacemente il modello: un filtro di base di commit può dirti se la macro è stata utilizzata:

  • se utilizzato, è probabile che il modello venga applicato e, soprattutto, correttamente applicato (basta controllare che sia presente una protectedparola chiave),
  • se non utilizzato, puoi provare a indagare sul perché non lo fosse.

Senza macro, è necessario verificare se il modello è necessario e ben implementato in tutti i casi.

Soluzione migliore

  • C'è un'altra soluzione migliore?

Affettare in C ++ non è altro che una peculiarità del linguaggio. Dal momento che stai scrivendo una linea guida (specialmente per i neofiti), dovresti concentrarti sull'insegnamento e non solo sull'enumerazione delle "regole di codifica". Devi assicurarti di spiegare davvero come e perché si verifica la suddivisione, insieme ad esempi ed esercizi (non reinventare la ruota, trarre ispirazione da libri e tutorial).

Ad esempio, il titolo di un esercizio potrebbe essere " Qual è lo schema per un'interfaccia sicura in C ++ ?"

Quindi, la tua mossa migliore sarebbe quella di garantire che i tuoi sviluppatori C ++ capiscano cosa sta succedendo quando si verifica lo slicing. Sono convinto che se lo faranno, non faranno nel codice quanti errori temeresti, anche senza applicare formalmente quel particolare modello (ma puoi ancora applicarlo, gli avvisi del compilatore sono buoni).

Informazioni sul compilatore

Tu dici :

Non ho alcun potere sulla scelta dei compilatori per questo prodotto,

Spesso le persone diranno "Non ho il diritto di fare [X]" , "Non dovrei fare [Y] ..." , ... perché pensano che ciò non sia possibile e non perché provato o chiesto.

Probabilmente fa parte della descrizione del tuo lavoro dare la tua opinione in merito a problemi tecnici; se pensi davvero che il compilatore sia la scelta perfetta (o unica) per il tuo dominio problematico, allora usalo. Ma hai anche detto "i puri distruttori virtuali con implementazione inline non sono il peggior punto di soffocamento che abbia mai visto" ; dal mio punto di vista, il compilatore è così speciale che anche gli sviluppatori C ++ ben informati hanno difficoltà a usarlo: il tuo compilatore legacy / interno ora è un debito tecnico e hai il diritto (il dovere?) di discutere di questo problema con altri sviluppatori e gestori .

Prova a valutare il costo di conservazione del compilatore rispetto al costo di utilizzo di un altro:

  1. Cosa ti offre l'attuale compilatore che nessun altro può fare?
  2. Il tuo codice prodotto è facilmente compilabile utilizzando un altro compilatore? Perché no ?

Non conosco la tua situazione, e in effetti probabilmente hai validi motivi per essere legato a un compilatore specifico.
Ma nel caso si tratti di una semplice inerzia, la situazione non si evolverà mai se tu o i tuoi colleghi non segnalate problemi di produttività o debito tecnico.


Am I paranoid...: "Rendi le tue interfacce facili da usare correttamente e difficili da usare in modo errato". Ho assaporato quel particolare principio quando qualcuno ha riferito che uno dei miei metodi statici è stato, per errore, usato in modo errato. L'errore prodotto sembrava non correlato e ci sono volute più ore da un ingegnere per trovare la fonte. Questo "errore di interfaccia" è alla pari con l'assegnazione di un riferimento all'interfaccia a un altro. Quindi sì, voglio evitare questo tipo di errore. Inoltre, in C ++, la filosofia è catturare il più possibile al momento della compilazione, e il linguaggio ci dà quel potere, quindi andiamo con esso.
paercebal,

Best solution: Sono d'accordo. . . Better solution: Questa è una risposta fantastica. Ci lavorerò su ... Ora, riguardo al Pure virtual classes: Cos'è questo? Un'interfaccia astratta in C ++? (classe senza stato e solo metodi virtuali puri?). In che modo questa "pura classe virtuale" mi ha protetto dall'affettare? (i metodi virtuali puri non compileranno l'istanza, ma lo faranno i compiti di copia, e anche i compiti di spostamento saranno IIRC).
paercebal,

About the compiler: Siamo d'accordo, ma i nostri compilatori sono al di fuori del mio ambito di responsabilità (non che mi impedisca di fare commenti sprezzanti ... :-p ...). Non divulgherò i dettagli (vorrei poterlo fare) ma è legato a motivi interni (come le suite di test) e motivi esterni (ad esempio il collegamento dei clienti con le nostre librerie). Alla fine, cambiare la versione del compilatore (o persino patcharla) NON è un'operazione banale. Per non parlare di sostituire un compilatore rotto con un recente gcc.
paercebal,

@paercebal grazie per i tuoi commenti; riguardo alle pure classi virtuali, hai ragione, non risolve tutti i tuoi vincoli (rimuoverò questa parte). Comprendo la parte "errore di interfaccia" e come è utile rilevare gli errori in fase di compilazione: ma hai chiesto se sei paranoico e penso che l'approccio razionale sia quello di bilanciare la necessità di controlli statici con la probabilità dell'errore che si sta verificando. Buona fortuna con il compilatore :)
coredump

1
Non sono un fan delle macro, soprattutto perché le linee guida sono rivolte (anche) ai ragazzi. Troppo spesso ho visto persone a cui erano stati forniti strumenti così "pratici" per applicarli alla cieca e non capire mai cosa stesse realmente accadendo. Arrivano a credere che ciò che fa la macro debba essere la cosa più complicata perché il loro capo pensava che sarebbe troppo difficile per loro fare da soli. E poiché la macro esiste solo nella tua azienda, non possono nemmeno fare una ricerca sul web per essa, mentre per una guida documentata quali funzioni del membro dichiarare e perché, potrebbero farlo.
5gon12eder

2

Il problema del slicing è uno, ma certamente non l'unico, introdotto quando si espone un'interfaccia polimorfica di runtime ai propri utenti. Pensa a puntatori nulli, gestione della memoria, dati condivisi. Nessuno di questi si risolve facilmente in tutti i casi (i puntatori intelligenti sono fantastici, ma anche non sono proiettili d'argento). In effetti, dal tuo post non sembra che tu stia provando a risolvere il problema del slicing, ma piuttosto evitalo impedendo agli utenti di fare copie. Tutto quello che devi fare per offrire una soluzione al problema di slicing è aggiungere una funzione di membro clone virtuale. Penso che il problema più profondo nell'esporre un'interfaccia polimorfica di runtime sia che costringi gli utenti a gestire la semantica di riferimento, che è più difficile ragionare rispetto alla semantica di valore.

Il modo migliore che conosco per evitare questi problemi in C ++ è usare la cancellazione del tipo . Questa è una tecnica in cui si nasconde un'interfaccia polimorfica di runtime, dietro un'interfaccia di classe normale. Questa normale interfaccia di classe ha quindi una semantica di valore e si occupa di tutto il "pasticcio" polimorfico dietro gli schermi. std::functionè un ottimo esempio di cancellazione del tipo.

Per una grande spiegazione del perché esporre l'ereditarietà ai tuoi utenti è male e in che modo la cancellazione del tipo può aiutare a risolvere il problema che vede queste presentazioni di Sean Parent:

Inheritance Is The Base Class of Evil (versione corta)

Polimorfismo basato sulla semantica e sui concetti di valore (versione lunga; più facile da seguire, ma il suono non è eccezionale)


0

Non sei paranoico. Il mio primo incarico professionale come programmatore C ++ ha provocato lo slicing e il crash. Conosco altri. Non ci sono molte buone soluzioni per questo.

Dati i vincoli del compilatore, l'opzione 2 è la migliore. Invece di creare una macro, che i tuoi nuovi programmatori considereranno strana e misteriosa, suggerirei uno script o uno strumento per generare automaticamente il codice. Se i tuoi nuovi dipendenti useranno un IDE, dovresti essere in grado di creare uno strumento "Nuova interfaccia MYCOMPANY" che ti chiederà il nome dell'interfaccia e crei la struttura che stai cercando.

Se i tuoi programmatori utilizzano la riga di comando, utilizza il linguaggio di scripting disponibile per creare lo script NewMyCompanyInterface per generare il codice.

Ho usato questo approccio in passato per schemi di codice comuni (interfacce, macchine a stati, ecc.). La parte interessante è che i nuovi programmatori possono leggere l'output e capirlo facilmente, riproducendo il codice necessario quando hanno bisogno di qualcosa che non può essere generato.

Le macro e altri approcci di meta-programmazione tendono a offuscare ciò che sta accadendo, ei nuovi programmatori non imparano ciò che sta accadendo "dietro le quinte". Quando devono rompere il modello, sono persi come prima.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.