L'anno è il 2017 - Il preprocessore è ancora necessario in C++?

L'anno è il 2017 - Il preprocessore è ancora necessario in C++?

Il preprocessore C++, eh C, è meraviglioso.

Beh, no, non è meraviglioso.

È uno strumento primitivo di sostituzione del testo che deve essere utilizzato per funzionare con C++. Ma è davvero vero il "deve"? La maggior parte dell'utilizzo è diventata obsoleta grazie alle nuove e migliori funzionalità del linguaggio C++. E molte altre funzionalità come i moduli arriveranno presto™ .Quindi possiamo sbarazzarci del preprocessore? E se sì, come possiamo farlo?

Gran parte dell'uso del preprocessore è già una cattiva pratica:non usarlo per costanti simboliche, non usarlo per funzioni inline ecc.

Ma ci sono ancora alcuni modi in cui viene utilizzato in C++ idiomatico. Esaminiamoli e vediamo quale alternativa abbiamo.

Inclusione del file di intestazione

Iniziamo con l'utilizzo più comune:#include un file di intestazione.

Perché è necessario il preprocessore?

Per compilare un file sorgente, il compilatore deve vedere le dichiarazioni di tutte le funzioni che vengono chiamate. Quindi, se si definisce una funzione in un file e si desidera chiamarla in un altro, è necessario dichiararla in quel file come bene. Solo allora il compilatore può generare il codice appropriato per chiamare la funzione.

Ovviamente, copiare manualmente la dichiarazione può portare ad errori:se modifichi la firma devi modificare anche tutte le dichiarazioni. Quindi, invece di copiare manualmente le dichiarazioni, le scrivi in ​​un file speciale - il file di intestazione e lascia che il preprocessore copialo per te con #include .Ora devi ancora aggiornare tutte le dichiarazioni, ma in un'unica posizione.

Ma l'inclusione di testo normale è stupida. A volte può succedere che lo stesso file venga incluso due volte, il che porta a due copie di quel file. Questo non è un problema per le dichiarazioni di funzione, ma se hai definizioni di classe in un file di intestazione, è un errore .

Per evitarlo, devi usare include guard o il non standard #pragma once .

Come possiamo sostituirlo?

Con le attuali funzionalità di C++, non possiamo (senza ricorrere alla copia della pasta).

Ma con i moduli TS possiamo. Invece di fornire file di intestazione e file di origine, possiamo scrivere un modulo e import quello.

Se vuoi saperne di più sui moduli, ti consiglio vivamente il più recente CppChat.

Compilazione condizionale

Il secondo lavoro più comune del preprocessore è la compilazione condizionale:modificare le definizioni/dichiarazioni definendo o meno una macro.

Perché è necessario il preprocessore?

Considera la situazione in cui stai scrivendo una libreria che fornisce una funzione draw_triangle() che disegna un singolo triangolo sullo schermo.

Ora la dichiarazione è semplice:

// draws a single triangle
void draw_triangle();

Ma l'implementazione della funzione cambia a seconda del sistema operativo, del window manager, del display manager e/o delle fasi lunari (per il window manager esotico).

Quindi hai bisogno di qualcosa del genere:

// use this one for Windows
void draw_triangle()
{
 // create window using the WinAPI 
 // draw triangle using DirectX
}

// use this one for Linux
void draw_triangle()
{
 // create window using X11
 // draw triangle using OpenGL
}

Il preprocessore aiuta qui:

#if _WIN32
 // Windows triangle drawing code here 
#else
 // Linux triangle drawing code here
#endif

Il codice nel ramo che non viene preso verrà cancellato prima della compilazione, quindi non avremo errori su API mancanti ecc.

Come possiamo sostituirlo?

C++17 aggiunge if constexpr , questo può essere usato per sostituire il semplice #if … #else :

Invece di questo:

void do_sth()
{
 #if DEBUG_MODE
 log();
 #endif
 …
}

Possiamo scrivere questo:

void do_sth()
{
 if constexpr (DEBUG_MODE)
 {
 log();
 }

 …
}

Se DEBUG_MODE è false , quindi il ramo non verrà compilato correttamente, verificherà solo gli errori di sintassi, in modo simile al controllo effettuato per un modello non ancora istanziato.

Questo è anche meglio di #if poiché individuerà errori evidenti nel codice senza controllare tutte le combinazioni di macro. Un altro vantaggio con if constexpr è quel DEBUG_MODE ora può essere un normale constexpr variabile,invece di una costante proveniente da un'espansione macro.

Naturalmente, if constexpr presenta aspetti negativi :Non puoi usarlo per vincolare le direttive del preprocessore, ad esempio #include .Per il draw_triangle() ad esempio, il codice deve includere l'intestazione di sistema corretta.if constexpr può aiutare, quindi avresti bisogno di una vera compilazione condizionale lì o di copiare manualmente le dichiarazioni.

E anche i moduli non possono aiutare poiché le intestazioni di sistema non definiscono alcun modulo che puoi importare. Inoltre, non puoi importare condizionalmente un modulo (per quanto ne so).

Opzioni di configurazione superate

In una nota correlata, a volte vuoi passare alcune opzioni di configurazione a una libreria. Potresti voler abilitare o disabilitare asserzioni, controlli delle precondizioni, modificare alcuni comportamenti predefiniti...

Ad esempio, potrebbe avere un'intestazione come questa:

#ifndef USE_ASSERTIONS
 // default to enable
 #define USE_ASSERTIONS 1
#endif

#ifndef DEFAULT_FOO_IMPLEMENTATION
 // use the general implementation
 #define DEFAULT_FOO_IMPLEMENTATION general_foo
#endif

…

Durante la creazione della libreria è quindi possibile sovrascrivere le macro richiamando il compilatore o tramite CMake, ad esempio.

Come possiamo sostituirlo?

Le macro sono la scelta più ovvia qui, ma c'è un'alternativa:

Potremmo utilizzare una strategia diversa per passare opzioni, come la progettazione basata su criteri, in cui si passa un criterio a un modello di classe che definisce il comportamento scelto. Ciò ha il vantaggio di non forzare una singola implementazione a tutti gli utenti, ma di il corso ha i suoi aspetti negativi.

Ma quello che mi piacerebbe davvero vedere è la possibilità di passare queste opzioni di configurazione quando import il modulo:

import my.module(use_assertions = false);
…

Questo sarebbe il sostituto ideale per:

#define USE_ASSERTIONS 0
#include "my_library.hpp"

Ma non penso che sia tecnicamente fattibile senza sacrificare i vantaggi offerti dai moduli, ad es. moduli di precompilazione.

Macro di asserzione

La macro che utilizzerai più comunemente probabilmente fa una sorta di affermazione. E le macro sono la scelta più ovvia qui:

  • Dovrai disabilitare condizionalmente le asserzioni e rimuoverle in modo che non abbiano un sovraccarico nel rilascio.
  • Se hai una macro, puoi utilizzare il __LINE__ predefinito , __FILE__ e __func__ per ottenere la posizione in cui si trova l'asserzione e utilizzarla nella diagnostica.
  • Se hai una macro, puoi anche stringere l'espressione che viene controllata e usarla anche nella diagnostica.

Ecco perché quasi tutte le asserzioni sono macro.

Come possiamo sostituirlo?

Ho già esplorato come sostituire la compilazione condizionale e come puoi specificare se devono essere abilitate o meno, quindi non c'è problema.

È possibile ottenere le informazioni sul file anche in Library Fundamentals TS v2 poiché aggiunge std::experimental::source_location :

void my_assertion(bool expr, std::experimental::source_location loc = std::experimental::source_location::current())
{
 if (!expr)
 report_error(loc.file_name, loc.line, loc.function_name);
}

La funzione std::experimental::source_location::current() si espande alle informazioni sul file di origine al momento della scrittura. Inoltre, se lo usi come argomento predefinito, si espanderà nella posizione del chiamante. Quindi anche il secondo punto non è un problema.

Il terzo punto è quello critico:non puoi stringere l'espressione e stamparla nella diagnostica senza usare una macro. Se sei d'accordo, puoi implementare la tua funzione di asserzione oggi.

Ma per il resto hai ancora bisogno di una macro per quello. Dai un'occhiata a questo post del blog come potresti implementare una funzione di asserzione (quasi) senza macro, dove puoi controllare il livello con constexpr variabili anziché macro. Puoi trovare l'implementazione completa qui.

Macro di compatibilità

Non tutti i compilatori supportano tutte le funzionalità di C++, il che rende il porting un vero problema, soprattutto se non hai accesso a un compilatore per un test e devi fare il "cambia una riga, fai il push in CI, attendi la compilazione del CI, cambia un altro line” ciclo solo perché a qualche compilatore non piace davvero una caratteristica importante di C++!

Ad ogni modo, i soliti problemi di compatibilità possono essere risolti con le macro. Le implementazioni definiscono anche alcune macro una volta implementata una funzionalità, rendendo il controllo banale:

#if __cpp_noexcept
 #define NOEXCEPT noexcept
 #define NOEXCEPT_COND(Cond) noexcept(Cond)
 #define NOEXCEPT_OP(Expr) noexcept(Expr)
#else
 #define NOEXCEPT
 #define NOEXCEPT_COND(Cond)
 #define NOEXCEPT_OP(Expr) false
#endif

…

void func() NOEXCEPT
{
 …
}

Ciò consente un utilizzo portabile delle funzionalità anche se non tutti i compilatori le hanno già.

Come possiamo sostituirlo?

Non possiamo farlo in nessun altro modo. La soluzione per le funzionalità mancanti richiede una sorta di strumento di preelaborazione per eliminare le funzionalità non supportate. Qui dobbiamo usare le macro.

Macro boilerplate

I modelli di C++ e TMP fanno molto per eliminare molto codice standard che altrimenti devi scrivere. Ma a volte, devi solo scrivere molto codice che è lo stesso ma non del tutto lo stesso:

struct less
{
 bool operator()(const foo& a, const foo& b)
 {
 return a.bar < b.bar;
 }
};

struct greater
{
 bool operator()(const foo& a, const foo& b)
 {
 return a.bar > b.bar;
 }
};

…

Le macro possono generare quel boilerplate per te:

#define MAKE_COMP(Name, Op) \
struct Name \
{ \
 bool operator()(const foo& a, const foo& b) \
 { \
 return a.bar Op b.bar; \
 } \
};

MAKE_COMP(less, <)
MAKE_COMP(greater, >)
MAKE_COMP(less_equal, <=)
MAKE_COMP(greater_equal, >=)

#undef MAKE_COMP

Questo può davvero farti risparmiare un sacco di codice ripetitivo.

Oppure considera il caso in cui è necessario aggirare il brutto codice SFINAE:

#define REQUIRES(Trait) \
 typename std::enable_if<Trait::value, int>::type = 0

template <typename T, REQUIRES(std::is_integral<T>)>
void foo() {}

Oppure devi generare il to_string() implementazione per un enum ,è un compito semplice con le macro X:

// in enum_members.hpp
X(foo)
X(bar)
X(baz)

// in header.hpp
enum class my_enum
{
 // expand enum names as-is
 #define X(x) x,
 #include "enum_members.hpp"
 #undef X
};

const char* to_string(my_enum e)
{
 switch (e)
 {
 // generate case
 #define X(x) \
 case my_enum::x: \
 return #x;
 #include "enum_members.hpp"
 #undef X
 };
};

Semplificano solo la lettura e l'utilizzo di molto codice:non hai bisogno di copia-incolla, non hai bisogno di strumenti fantasiosi e non c'è un vero "pericolo" per l'utente.

Come possiamo sostituirlo?

Non possiamo sostituirli tutti con una singola funzionalità di linguaggio. Per il primo, abbiamo bisogno di un modo per passare una funzione sovraccaricata (come un operatore) a un modello, quindi potremmo passarlo come parametro del modello e semplicemente aliasarlo. Per il secondo, abbiamo bisogno di concetti. E per il terzo, abbiamo bisogno di riflessione.

Quindi non c'è modo di sbarazzarsi di tali macro boilerplate senza ricorrere alla scrittura manuale del codice boilerplate.

Conclusione

Con l'attuale C++(17), la maggior parte dell'utilizzo del preprocessore non può essere sostituita facilmente.

I Moduli TS consentono una sostituzione dell'uso più comune - #include ,ma a volte è comunque necessario il preprocessore, soprattutto per garantire la compatibilità della piattaforma e del compilatore.

E anche allora:lo ritengo corretto le macro, che fanno parte del compilatore e strumenti molto potenti per la generazione AST, sono una cosa utile da avere. Qualcosa come le metaclassi di Herb Sutter, per esempio. Tuttavia, non voglio assolutamente la sostituzione del testo primitivo di #define .