C++ enum VS #define

Vulpecula

Commander
Registriert
Nov. 2007
Beiträge
2.241
Hallo zusammen!

Vielleicht kann mir jemand von Euch eine Frage beantworten, die (zumindest gehe ich davon aus) auf Erfahrung beruht. :)

Und zwar schreibe ich gerade an einer eigenen mbed-Library für einen BMP280, damit ich mal irgendwas konkretes habe und nicht nur LEDs zum Blinken bringe. ;) Diesbezüglich habe ich mir natürlich auch mal Bibliotheken von anderen angesehen. Hierbei fällt mir auf, dass das Speichern von bestimmten Optionen durchaus unterschiedlich gelöst wurde. Ich meine damit konkret die möglichen Settings, die ich in die Register des Sensors schreiben kann.

Adafruit löst es z.B., indem sie die Bitvektoren in Aufzählungstypen ablegen:

C++:
enum sensor_sampling {
  SAMPLING_NONE = 0x00, /** No over-sampling. */
  SAMPLING_X1 = 0x01, /** 1x over-sampling. */
  SAMPLING_X2 = 0x02, /** 2x over-sampling. */
  SAMPLING_X4 = 0x03, /** 4x over-sampling. */
  SAMPLING_X8 = 0x04, /** 8x over-sampling. */
  SAMPLING_X16 = 0x05 /** 16x over-sampling. */
};

Andere Bibliotheken (wie z.B. der Driver von Bosch Sensortec selbst) machen das wiederum als Präprozessordirektive:

C:
/*! @name Over-sampling macros */
#define BMP280_OS_NONE    UINT8_C(0x00)
#define BMP280_OS_1X      UINT8_C(0x01)
#define BMP280_OS_2X      UINT8_C(0x02)
#define BMP280_OS_4X      UINT8_C(0x03)
#define BMP280_OS_8X      UINT8_C(0x04)

Gibt es hier sowas wie "Best Practice", oder ist es am Ende egal? Ich hab mich persönlich ein wenig daran gewöhnt, vieles über #define zu machen, aber es muss ja einen Grund geben, warum es nicht selten anders gelöst wurde.

Grüße
Vulpecula
 
Bei C++ gibt es bessere Alternativen zu #define und diese sind daher best practice, unter anderem enum.
Siehe z.B. diese Antwort. Einige potenzielle Probleme sind dort aufgeführt, aber die sind für ein übersichtliches Projekt nicht schwerwiegend.
Du willst generell mit den Werkzeugen und Mechanismen arbeiten, die die jeweilige Sprache anbietet und nicht an diesen vorbei (genau das macht ein #define ja letztendlich), das ist stets weniger fehleranfällig. In etwa vergleichbar mit z.b. casts. Nur nutzen, wenn man explizit genau das braucht und weiß, warum die Alternativen hier nicht sinnvoll sind.

Ich spreche hier nicht aus Erfahrung mit embedded systems.
 
  • Gefällt mir
Reaktionen: Vulpecula
#define macht ja nix anderes als vor dem Kompilieren alle Vorkommen von BMP280_OS_NONE mit UINT8_C(0x00) zu ersetzen. Du hättest also überall direkt den Wert im Code hinschreiben können. Der Platzhalter ist halt ganz praktisch wenn du diesen konkreten Wert irgendwo brauchst, bei Änderungen aber nicht deinen ganzen Quelltext durchgehen möchtest.

Da es sich bei #define aber um einen reinen Tausch handelt, so müsstest du einen Funktionsparameter "sensor_sampling", den du vielleicht brauchst, als UINT8_C definieren. Dies bedeutet aber du kannst die Funktion dann mit jedem beliebigen UINT8_C Wert aufrufen, auch wenn dies gar nicht gewollt ist. Beim Kompilieren würdest du bei "falschen" Werten aber niemals einen Fehler sehen.

Wenn du in C++ stattdessen ein Enum verwendest, so kannst du deinen Funktionsparameter auch als diesen Enumstyp definieren. (das sollte jedenfalls gehen, bin in C++ etwas rostig) D.h. du hast für dich selbst beim Kompilieren einen Test, dass die Funktion auch nur mit den korrekten Werten aufgerufen wird.

Ich habe zwar Erfahrung mit C in Mikrocontrollern bin aber kein Experte (das ist man bei C/C++ wohl nie ;) ).
Jedenfalls ist das mein Eindruck vom Unterschied der beiden Optionen.

Edit: Hab' grad nachgeschaut, so wie ich das sehe hat C auch Enums. Der Unterschied ist dann halt ganz klar Austausch vs eigener Datentyp.
 
Zuletzt bearbeitet:
  • Gefällt mir
Reaktionen: Vulpecula und BeBur
Zuletzt bearbeitet:
  • Gefällt mir
Reaktionen: gummiwipfel und Vulpecula
Als wichtige technische Punkte würde ich sehen:

- Ein Enum ist ein Typ. Den kann der Compiler prüfen. Wichtig z.B. bei sowas:
C:
enum { FOO, BAR, BAZ } state;
switch (state) {
case FOO: break;
case BAZ: break;
/* Oops, ein Fall fehlt. */
}
  • Enums können einen Scope haben.
  • Enum-Konstanten sind sogenannte "Integer Constant Expressions" und haben daher keine Nachteile gegenüber einer direkten Zahl oder einem #define. (Wichtig für Arrays und Templates.)
 
  • Gefällt mir
Reaktionen: Vulpecula
Ich vermute, dass #define insbesondere deswegen so häufig genutzt wurde, weil die "alten" PIC und AVR Mikrocontroller deutlich weniger Speicher haben als es mittlerweile z.B. bei den ARM Cortex MCUs der Fall ist. Bei ganzen Makros will ich mich jetzt nicht so weit aus dem Fenster lehnen, aber bei reinen Ersetzungen (zum Beispiel bei Registeradressen) ergibt das Ganze dann auch Sinn, da man je nach Peripherie gerne mal dutzende Adressen hat. Jedes mal ein kostbares Byte zu opfern, um eine Adresse in einer Variable zu parken, schmerz da mehr als die Probleme, die bei der Verwendung von #define auftreten können.



Wenn ich das Ganze nun mit enums umsetzten will, würde ich also zum Beispiel, meine Register folgendermaßen deklarieren:

C++:
enum class Addresses : uint8_t
{
    CALIBRATION_DATA_REGISTERS   = 0x88,
    CHIP_ID_REGISTER             = 0xD0,
    RESET_REGISTER               = 0xE0,
    STATUS_REGISTER              = 0xF3,
    MEASUREMENT_CONTROl_REGISTER = 0xF4,
    CONFIG_REGISTER              = 0xF5,
    PRESSURE_MSB_REGISTER        = 0xF7,
    PRESSURE_LSB_REGISTER        = 0xF8,
    PRESSURE_XLSB_REGISTER       = 0xF9,
    TEMPERATURE_MSB_REGISTER     = 0xFA,
    TEMPERATURE_LSB_REGISTER     = 0xFB,
    TEMPERATURE_XLSB_REGISTER    = 0xFC
};

Ich habe hier den Typ uint8_t gewählt, weil die Adressen genau das sind. Allerdings: Die Darüberliegende API verlangt einen int32_t:

int write(int address, const char * data, int length, bool repeated = false)
Ergo sollte/müsste ich die Aufzählung auch als int deklarieren, richitg? (Also keinen Typ angeben, da ja standardmäßig int als Datentyp benutzt wird).

Es weiteren frage ich mich, wie ich dann darauf zugreife bzw. wie die Syntax lauten müsste, damit ich eine Adresse aus der Aufzählung richtig verwenden kann. Wenn ich das richtig verstehe, muss ich vorher eine Variable (besser: ein Objekt) vom Typ Addresses instanziieren, welches ich dann in der write Funktion benutze:

C++:
Addresses chip_id_register = CHIP_ID_REGISTER;
write(chip_id_register, data, length);
...

Das allerdings funktioniert nicht so, wie ich mir das vorstelle. Vermutlich habe ich da etwas noch nicht verstanden.

Es ist aber auch so, dass es mir (hinsichtlich des Einsatzzwecks) so vorkommt, als würde man mit Kanonen auf Spatzen schießen. Ich benötige die gesamte Funktionalität hinter enum bzw. enum class in diesem Fall ja gar nicht. Ergo wäre eine Präprozessordirektive hier doch Vorteilhafter*.

Grüße
Vulpecula

*) Edit: Nicht in den Falschen hals bekommen... Das soll kein Plädoyer pro #define sein. Ich versuche nur, die Sache zu verstehen. ;)
 
Versuch mal

Addresses::CHIP_ID_REGISTER

So brauchst du keine Variable.
 
  • Gefällt mir
Reaktionen: Vulpecula
Ah, okay... d.h. den Namespace muss ich zwingend angeben. Mir war so, als hätte ich schon Beispiele gesehen, in denen das nicht so war. Folgendes funktioniert:

printf("Chip ID Register Address: %d\n", Addresses::CHIP_ID_REGISTER);
 
Vulpecula schrieb:
Ich benötige die gesamte Funktionalität hinter enum bzw. enum class in diesem Fall ja gar nicht. Ergo wäre eine Präprozessordirektive hier doch Vorteilhafter*.
Vermeintliche Vorteile.

Ich habe mal alle 4 Varianten in Code* übersetzt:
  • enum mit Variable
  • enum ohne Variable
  • define mit Variable
  • define ohne Variable
https://godbolt.org/z/1e8Wrevc7

Und wie du siehst, kommt überall die selbe Assembly raus.
Folglich hast du durchs enum nur gewonnen.

C++:
#include <memory>

int write(int address, const char * data, int length, bool repeated = false);

enum class Addresses : uint8_t
{
    CALIBRATION_DATA_REGISTERS   = 0x88,
    CHIP_ID_REGISTER             = 0xD0,
    RESET_REGISTER               = 0xE0,
    STATUS_REGISTER              = 0xF3,
    MEASUREMENT_CONTROl_REGISTER = 0xF4,
    CONFIG_REGISTER              = 0xF5,
    PRESSURE_MSB_REGISTER        = 0xF7,
    PRESSURE_LSB_REGISTER        = 0xF8,
    PRESSURE_XLSB_REGISTER       = 0xF9,
    TEMPERATURE_MSB_REGISTER     = 0xFA,
    TEMPERATURE_LSB_REGISTER     = 0xFB,
    TEMPERATURE_XLSB_REGISTER    = 0xFC
};

/*! @name Over-sampling macros */
#define BMP280_OS_NONE    UINT8_C(0x00)
#define BMP280_OS_1X      UINT8_C(0x01)
#define BMP280_OS_2X      UINT8_C(0x02)
#define BMP280_OS_4X      UINT8_C(0x03)
#define BMP280_OS_8X      UINT8_C(0x04)


void with_defines(){
    char a = 5;
    int chip_id_register = BMP280_OS_2X;
    write(chip_id_register, &a, 2345);
}

void with_enums(){
    char a = 5;
    Addresses chip_id_register = Addresses::CHIP_ID_REGISTER;
    write(static_cast<int>(chip_id_register), &a, 2345);
}

void with_enums_without_variable(){
    char a = 5;
    write(static_cast<int>(Addresses::CHIP_ID_REGISTER), &a, 2345);
}

void with_defines_without_variable(){
    char a = 5;
    write(BMP280_OS_2X, &a, 2345);
}
 
Zuletzt bearbeitet:
  • Gefällt mir
Reaktionen: BeBur und Vulpecula
Danke @KitKat::new() für die Mühe. Damit gewinne ich noch ein wenig mehr 'insight'. :)

Mich nervt die write Funktion allerdings ein wenig:
int write (int address, const char * data, int length, bool repeated = false)

Dadurch, dass data vom Typ const char * ist, muss ich jedes mal konvertieren/casten. Zum Beispiel so:
C++:
char id_register[1] = { static_cast<char>(Registers::CHIP_ID_REGISTER_ADDRESS) };
i2cInterface->write(i2cAddress, id_register, 1, true);

Gibt es da eine Möglichkeit, das Ganze etwas simpler zu gestalten (also ohne extra ein char Array anlegen zu müssen)? Registers ist übrigens eine enum class vom Typ char, aber static cast scheine ich trotzdem zu brauchen.
 
In C kannst du dir explizite Variable sparen mit einem Compound Literal (C99-Feature, kein C++; GCC und Clang sollten das aber supporten):
C:
write(i2cAddress, &(char[]) { CHIP_ID_REGISTER_ADDRESS }, 1, true);
Ergänzung ()

Vulpecula schrieb:
static cast scheine ich trotzdem zu brauchen.
Das ist der Sinn einer "enum class" :)
 
  • Gefällt mir
Reaktionen: KitKat::new() und Vulpecula
Vulpecula schrieb:
Dadurch, dass data vom Typ const char * ist, muss ich jedes mal konvertieren/casten. Zum Beispiel so:
Das ergibt fehlerhaften Code (EDIT: falsch gedacht, siehe nächster Post): Statt die Addresse zu übergeben übergibst du eine Addresse zur Addresse.
Das enum müsste den Typ char* haben:
C++:
enum Registers: char* {
  CHIP_ID_REGISTER_ADDRESS = 0x07,
};
(Warum ohne class? siehe unten)
Geht meines Wissens in C++ nicht 😐

Mein nächster Versuch wäre das enum wegzulassen und eine Konstante drauszumachen:
C++:
class Registers {
    public:
    static constexpr const char* CHIP_ID_REGISTER_ADDRESS = reinterpret_cast<char*>(0x07);
};
Hier scheiterts allerdings daran, dass reinterpret_cast keine constexpr ist, es aber in der Klasse benötigt wird.

Die imho nächstbeste Lösung wäre dann ein Namespace (um das Gruppieren beizubehalten), was dann so aussehen würde:
C++:
namespace Registers {
    static const char * CHIP_ID_REGISTER_ADDRESS = reinterpret_cast<char*>(0x07);
}
int main(){
    write(23, Registers::CHIP_ID_REGISTER_ADDRESS, 1, true);
}

Eine Alternative wäre den reinterpret_cast direkt beim Aufruf zu haben und stattdessen beim int/char-enum zu bleiben, allerdings halte ich es für sinnvoller reinterpret_casts nicht noch weiter über dem Code zu verstreuen.
Eine weitere Alternative wäre eine Funktion zu nutzen, welche von einem enum zu der Addresse konvertiert, allerdings komplizierter in der Nutzung...

Wäre toll, wenn da noch jemand anderes Input geben würde 😀

nullPtr schrieb:
Das ist der Sinn einer "enum class" :)
Wobei es in diesem Usecase an Nützlichkeit verliert - wenn sowieso in jeder Benutzung der Wert dahinter relevant ist. Oder?
Daher könnte man eigentlich aufs class verzichten.
 
Zuletzt bearbeitet:
  • Gefällt mir
Reaktionen: Vulpecula
KitKat::new() schrieb:
Das ergibt fehlerhaften Code: Statt die Addresse zu übergeben übergibst du eine Addresse zur Addresse.
Nein, das hat schon so gepasst. Die write Funktion in diesem Kontext sendet bytes über I2C. Die Adresse 0x07 ist erst für den Empfänger eine Adresse, für den Sender ist das einfach nur raw data.

Das würde also mit dem reinterpret_cast explodieren, oder wie auch immer dieser Microcontroller diesen Memory Access handled.

Prinzipiell stimme ich dir aber zu, würde das auch entweder in einer Klasse oder in einem Namespace als constexpr wrappen. Als enum class würde es nur Sinn machen, wenn man diese Adressen als Teil eines Interface nach außen zur Verfügung stellt und type safety haben will. Wenn das aber alles implementation details sind, und man sowieso immer auf char casten muss, um write damit zu füttern, bietet enum class keinen wirklichen Vorteil.

Gruß
BlackMark
 
  • Gefällt mir
Reaktionen: Vulpecula und KitKat::new()
BlackMark schrieb:
Nein, das hat schon so gepasst. Die write Funktion in diesem Kontext sendet bytes über I2C. Die Adresse 0x07 ist erst für den Empfänger eine Adresse, für den Sender ist das einfach nur raw data.
Achso, dann habe ich mir die meiste Mühe umsonst gemacht :D
 
  • Gefällt mir
Reaktionen: Vulpecula
KitKat::new() schrieb:
Achso, dann habe ich mir die meiste Mühe umsonst gemacht :D

Nein, das würde ich nicht sagen. Ich versuche, als allen Beträgen etwas mitzunehmen. Deswegen Dir und allen anderen auf jeden Fall ein dickes Dankeschön! 😊
 
Zur Ursprungsfrage:
Vermeide #define solange nicht unbedingt notwendig (und das ist es hier nicht). Simpel.

Zum Rest:
Einmal nutzt du das enum so:
Vulpecula schrieb:
C++:
Addresses chip_id_register = CHIP_ID_REGISTER;
write(chip_id_register, data, length);
...

Und einmal so:
Vulpecula schrieb:
C++:
char id_register[1] = { static_cast<char>(Registers::CHIP_ID_REGISTER_ADDRESS) };
i2cInterface->write(i2cAddress, id_register, 1, true);

Ists nun als Argument für den Parameter address oder Parameter data gedacht? Davon hängt ab welche Optionen dir für die Definition offen stehen, denn einmal muss es in in ein int konvertierbar sein, und beim anderen mal in ein char*, und das ist nun wirklich was ziemlich verschiedenes.
 
  • Gefällt mir
Reaktionen: Vulpecula
Ersteres ist definitiv falsch. Da hat sich anscheinend beim Übertragen ein Fehler eingeschlichen. Die Register-Adressen treten immer als "data" auf (also immer als const char *):
int write (int address, const char * data, int length, bool repeated = false)

int address hat hier immer den selben Wert (der die physikalische Adresse des Geräts am I2C-Bus beschreibt).
 
Vulpecula schrieb:
Ersteres ist definitiv falsch. Da hat sich anscheinend beim Übertragen ein Fehler eingeschlichen. Die Register-Adressen treten immer als "data" auf (also immer als const char *):
int write (int address, const char * data, int length, bool repeated = false)

Wenn das Ziel ein const char * ist, dann müssen die relevanten Werte also irgendwann mal in ein char reinkommen um einen nicht-portablen cast zu vermeiden.
Du könntest zwar ein enum verwenden; wie von Vorrednern schon gesagt bringt dir in dem Fall die Deklaration als
C++:
enum class
aber eigentlich nix, da du keinen neuen Typ fürs Typsystem kreieren willst, sondern einfach nur vordefinierte Werte abspeichern willst. Auch die Angabe der Breite des Typs hinter dem enum (z.b. uint8_t oder int) bringt dir hier nix, es muss sowieso irgendwann mal zu char konvertiert werden.

Und da ein enum eh nicht wirklich das ist was du suchst (eher ein hack), scheint die zielführendste Lösung static constexpr char zu sein (ebenfalls schon von Vorrednern genannt). Das kannst dann kompakt so auflisten:
C++:
static constexpr char CALIBRATION_DATA_REGISTERS   = 0x88,
                      CHIP_ID_REGISTER             = 0xD0,
                      RESET_REGISTER               = 0xE0;

Und der write-Aufruf schaut dann so aus:

C++:
i2cInterface->write(i2cAddress, &CALIBRATION_DATA_REGISTERS, sizeof(CALIBRATION_DATA_REGISTERS), true);
Der guten Praxis halber solltest du dir angewöhnen, die Länge der zu übermittelnden Daten immer über ein sizeof oder äquivalentes zu ermittlen und nicht als "magische Konstanten" angeben, welche zwar für die aktuelle Situation passen aber a) wenig ausdrucksstark sind für was sie eigentlich stehen, und b) Tür und Tor für bugs in späteren Modifikationen öffnen.
 
  • Gefällt mir
Reaktionen: Vulpecula
firespot schrieb:
static constexpr char CALIBRATION_DATA_REGISTERS = 0x88
Das Problem mit char als Datentyp, ist, dass das ein signed char sein kann, und dann zu
Code:
warning: overflow in conversion from 'int' to 'char' changes value from '136' to ''\37777777610'' [-Woverflow]
führt.

Ich würde
C++:
static constexpr auto CALIBRATION_DATA_REGISTERS = uint8_t{0x88};

i2cInterface->write(i2cAddress, reinterpret_cast<const char*>(&CALIBRATION_DATA_REGISTERS), sizeof(CALIBRATION_DATA_REGISTERS), true);
verwenden. Es ist nicht optimal, dass das I2C Interface const char* verwendet, obwohl es sich um raw data handelt. unsigned char bzw uint8_t wäre hier besser. Oder in modernem C++ std::byte.

Alternativ, wenn der reinterpret_cast stört, kann man auch bei char bleiben und die Warning mit einen explicit cast im Assignment unterdrücken. Standardkonform ist es mit dem Overflow bzw. Narrowing-conversion auf jeden Fall, weil der Standard ein eindeutiges unsigned char -> char -> unsigned char Mapping garantiert.

Gruß
BlackMark
 
  • Gefällt mir
Reaktionen: Vulpecula
BlackMark schrieb:
Alternativ, wenn der reinterpret_cast stört, kann man auch bei char bleiben und die Warning mit einen explicit cast im Assignment unterdrücken. Standardkonform ist es mit dem Overflow bzw. Narrowing-conversion auf jeden Fall, weil der Standard ein eindeutiges unsigned char -> char -> unsigned char Mapping garantiert.
Das Problem mit dem reinterpret_cast ist dass IMHO der Standard nicht garantiert dass das funktioniert:

Zitat aus https://en.cppreference.com/w/cpp/language/reinterpret_cast, die relevante Stelle fett markiert:

"3) A value of any integral or enumeration type can be converted to a pointer type. A pointer converted to an integer of sufficient size and back to the same pointer type is guaranteed to have its original value, otherwise the resulting pointer cannot be dereferenced safely (the round-trip conversion in the opposite direction is not guaranteed; the same pointer may have multiple integer representations)."

Somit ist nicht garantiert dass in write der ursprüngliche integer-Wert wiederhergestellt werden kann.

Und wenn write nur die ersten length-bytes vom übergebenen pointer-Wert nutzt, frage ich mich ob je nach little oder big endian es dann auch garantiert die "richtigen" bytes nutzt.

Solange wir aber nicht genau wissen, was write intern überhaupt mit den Parametern macht und in was es data zurückcastet, ist es generell schwierig hier komformen Code aufzusetzen.
 
  • Gefällt mir
Reaktionen: Vulpecula
Zurück
Oben