enum-Ersatz über Singleton-Klasse

Ich möchte das Thema enum nochmal aufgreifen.

Ich habe einen Anwendungsfall - die eModbus Library -, bei dem es sogenannte "function codes" gibt. Theoretisch von 1..127, und jeden darf es nur einmal geben. Es gibt 38 vom Modbus-Standard vorgegebene Codes, die nicht lückenlos angeordenet sind, sondern ziemlich verstreut über die 127 möglichen Werte.

Die naheliegende Implementierung ist dann ein enum wie:

enum FunctionCode : uint8_t {
  ANY_FUNCTION_CODE       = 0x00, // Only valid for server to register function codes
  READ_COIL               = 0x01,
  READ_DISCR_INPUT        = 0x02,
  READ_HOLD_REGISTER      = 0x03,
  READ_INPUT_REGISTER     = 0x04,
  WRITE_COIL              = 0x05,
  WRITE_HOLD_REGISTER     = 0x06,
  READ_EXCEPTION_SERIAL   = 0x07,
  DIAGNOSTICS_SERIAL      = 0x08,
  READ_COMM_CNT_SERIAL    = 0x0B,
  READ_COMM_LOG_SERIAL    = 0x0C,
  WRITE_MULT_COILS        = 0x0F,
...

Klappt soweit natürlich auch.

Jetzt gibt es Modbus-Geräte, die sich nicht an den Standard halten, sondern eigene Function Codes definieren, die nicht in den vom Standard vorgegebenen Bereichen liegen, sondern "illegale" Werte nutzen.

Die kann die Library nicht per se unterstützen, weil niemand weiß, was da alles kommen kann. Um die aber trotzdem einzubringen, müssen die Anwender bis dato das enum in der Library manipulieren und ihre eigenen Codes hinzufügen, damit der Rest (der aus gutem Grund den Datentyp FunctionCode benutzt) der Library damit hantieren kann. Doof, eigentlich.

Deswegen habe ich mir ein anderes Konzept überlegt und möchte es hier mal vorstellen. Grundidee ist es, für jeden Funktionscode eine Singletoninstanz einer FunctionCode-Klasse zu erzeugen, so dass die Library ihre Standardcodes vorgeben kann, Anwender aber typ-konform eigene dazu definieren. Und jeder Versuch, einen bereits existierenden Code erneut zu definieren, läuft auf eine reine Umbenennung desselben Codes im Userspace heraus, ohne dass die Originaldefinition damit zerstört würde.

Etwas kompliziert und braucht je Code auch mindestens ein uint8_t Speicher, kommt aber dem "erweiterbaren enum" recht nahe.

Hier der Code dazu:

#include <cstdio>
#include <cstdint>
#include <cstdlib>
#include <unordered_map>
#include <iostream>
#include <iomanip>

using std::unordered_map;
using std::cout;
using std::endl;

// FunctionCode: The interface to access the function code methods. 
// block() and private constructor will prevent direct instances
// New function codes will only be created with the NewFC template class (see below)
class FunctionCode {
protected:
  const uint8_t fc;                    // The function code
  static unordered_map<uint8_t, FunctionCode*> codes; // Map of all codes so far
  virtual void block() = 0;            // virtual function to prevent direct instances
  FunctionCode(uint8_t x) : fc(x) {    // Constructor accepting a uint8_t code
    // Collect code in map
    codes[x] = this;
  }
  virtual ~FunctionCode() {            // virtual destructor not to be called directly
    // Drop function code from map
    codes.erase(fc);
  }

public:
  FunctionCode() = delete;             // No empty constructor
  FunctionCode& operator=(const FunctionCode& other) = delete;  // No copy constructor
  operator auto() const { return fc; }  // Get function code as uint8_t

  // code(uint8_t f) shall give access to a known code or code 0 instead
  static const FunctionCode& code(uint8_t f) {
    unordered_map<uint8_t, FunctionCode*>::const_iterator got = codes.find(f);
    if (got == codes.end())
      return *codes[0];
    else
      return *got->second;
  }
};

// NewFC<n> will create a singleton instance for code <n> derived from FunctionCode
template <uint8_t f>
class NewFC : public FunctionCode {
private:
  // Private constructor will only be called internally
  NewFC() : FunctionCode(f) {
    static_assert(f < 128, "FunctionCode must be in [1..127].");
  }
  // Define virtual FunctionCode::block() to make class instantiable
  void block() { }
  ~NewFC() { }
public:
  // Only method seen from outside is instance()
  // Will return (and in case create) a single instance of the given code
  static NewFC& instance() {
    static NewFC Instance;
    return Instance;
  }
};

// Initialize static map
unordered_map<uint8_t, FunctionCode*> FunctionCode::codes;
// Create default function code 0
FunctionCode& NoCode = NewFC<0>::instance();

// tcode() is to test FunctionCode creation in non-global domain
void tfunc() {
  FunctionCode& D = NewFC<3>::instance();
}

int main(int argc, char **argv) {
  // Create some instances
  FunctionCode& A = NewFC<1>::instance();
  FunctionCode& B = NewFC<2>::instance();
  FunctionCode& C = NewFC<2>::instance();  // Second use - shall return the same instance as B!

  cout << "B is " << (B != C ? "not " : "") << "identical to C" << endl;

  // Print out all instances known yet
  cout << "pre tfunc()" << endl;
  for (uint8_t i=0; i<5; ++i) {
    cout << int(i) << ": '" << int(FunctionCode::code(i)) << "'" << endl;
  }

  // Call tfunc() to create another FunctionCode
  tfunc();

  // Print out all instances now existing
  cout << "post tfunc()" << endl;
  for (uint8_t i=0; i<5; ++i) {
    cout << int(i) << ": '" << int(FunctionCode::code(i)) << "'" << endl;
  }

  return 0;
}

Die Ausgabe des Programms ist dann

B is identical to C
pre tfunc()
0: '0'
1: '1'
2: '2'
3: '0'
4: '0'
post tfunc()
0: '0'
1: '1'
2: '2'
3: '3'
4: '0'

Man kann also irgendwo neue Codes definieren, die dann global zur Verfügung stehen, und bestehende nicht überschreiben.

imho daran spießt es sich.
Entweder du hast eine Aufzählung mit exakt den gewollten Werten - dann hast halt nur die vordefinierten Aufzählungen - oder du erlaubst alle 1..127 ... dann braucht es auch alle 127 in der Aufzählung. Sonst ist es eben keine Enumeration.

Ich kann mir noch nicht so recht vorstellen wie deine Codeschnipsel anwendbar wären. Ist auch egal, ich kann dir aber sagen wenn ich eine Modbus Library mit "selbstdefinierbaren" FunctionCodes bräuchte, würde ich mir wünschen es gäbe ein "add" zum Bekanntgeben eines von mir definierten FunctionCodes (und die lib soll ihn mir vieleicht ablehnen, wenn der schon belegt wäre) sowie die Übergabe einer callback Funktion für diesen Function Code, denn irgendwo muss ich ja auch definieren können was das Ding machen soll wenn der selbst definierte FC kommt.

Genau das gibt es ja in der Library. Du kannst einen Callback für einen bekannten FC anmelden. Mit dem Schnipsel ist es möglich, dass ein Anwender den FC drölfzig als "MeinerEiner" definiert und dann dafür genauso einen Callback anmelden kann, der exakt so behandelt wird, als wäre es READ_HOLD_REGISTER oder sonst ein Standardcode, aber eben typsicher.

Ich musste etwas nachdenken, bis ich dein Problem verstanden hatte!

Etwas vergleichbares, habe ich in der Wühlkiste. Zumindest beackert es eine ähnliche Baustelle

#include <Streaming.h> // die Lib findest du selber ;-)
Print &cout = Serial; // cout Emulation für "Arme"


enum class Grund:byte
{
  a = 1,
  b = 2,
};

enum class Extend1:byte
{
  d = 3,
  e = 4,
};

enum class Extend2:byte
{
  d = 4,
  e = 5,
};

template <typename EnumA, typename EnumB>
class EnumMerge
{
  private:
  union
  { 
    EnumA enuma;
    EnumB enumb;
    byte asByte;
  };
 

public:
  EnumMerge() {};
  EnumMerge(EnumA e): enuma(e){}
  EnumMerge(EnumB e): enumb(e){}
  operator EnumA() { return enuma;}
  operator EnumB() { return enumb;}
  operator byte()  { return asByte;}
};


using MyEnum1 = EnumMerge<Grund,Extend1>;
using MyEnum2 = EnumMerge<Grund,Extend2>;

void test1(MyEnum1 value){(void)value;}
void test2(MyEnum2 value){(void)value;}



void setup() 
{
  Serial.begin(9600);
  cout << F("Start: ") << F(__FILE__) << endl;
  cout << MyEnum1(Grund::a) << endl;
  
  test1(MyEnum1(Grund::a));
  test1(MyEnum1(Extend1::d));
  //test1(MyEnum1(Extend2::d));
  
  //test2(MyEnum1(Grund::a));
  //test2(MyEnum1(Extend1::d));
  //test2(MyEnum1(Extend2::d));

  //test1(MyEnum2(Grund::a));
  //test1(MyEnum2(Extend1::d));
  //test1(MyEnum2(Extend2::d));
  
  test2(MyEnum2(Grund::a));
  //test2(MyEnum2(Extend1::d));
  test2(MyEnum2(Extend2::d));
}

void loop() 
{

}

Habe ich es Verstanden?

Oha, das ist tricky! Ich wäre nie auf den Gedanken gekommen, eine union aus enum-Klassen zu bauen.
Wenn man die Kontrolle über beide enums hat, klappt das wohl. Damit die Library aber immer mit einem definierten "gemergeten" enum arbeiten kann, muss es beide von Anfang an geben, oder? Die Erweiterung könnte man für die Anwender freigeben und immer pauschal einen Merge machen, auch wenn sie leer ist.

Hallo,

warum nicht die Standard Codes in einem namespace in einem struct anlegen?
Bsp.

namespace Pins
{
  namespace Addr
  {
    struct Offset // Offsets der Registeradressen
    {
      // VPORTs
      const uint8_t vDir  = 0x00;
      const uint8_t vOut  = 0x01;
      const uint8_t vIn   = 0x02;
      const uint8_t vFlag = 0x03;
      // PORTs
      const uint8_t dir           = 0x00;
      const uint8_t dirSet        = 0x01;
      const uint8_t dirClear      = 0x02;
      const uint8_t dirToggle     = 0x03;
      const uint8_t out           = 0x04;
      const uint8_t outSet        = 0x05;
      const uint8_t outClear      = 0x06;
      const uint8_t outToggle     = 0x07;
      const uint8_t in            = 0x08;
      const uint8_t flag          = 0x09;
      const uint8_t portCtrl      = 0x0A;
      const uint8_t pinConfig     = 0x0B;
      const uint8_t pinCtrlUpdate = 0x0C;
      const uint8_t pinCtrlSet    = 0x0D;
      const uint8_t pinCtrlClear  = 0x0E;
    };  
    constexpr Offset addrOffset;

Wie bekommt dann ein Anwender seinen const uint8_t bla = 0x37; in die struct?

Hallo,

gar nicht, sind ja die Standard Codes. :wink: Abweichenden Code muss jeder selbst anlegen.
Erfahrene Anwender können das struct editieren. Wie ich das lese wäre das bei jedem Anwender sowieso individuell. Würde ich aber nicht pauschal vorschlagen. combie lässt auch nichts reingeschrieben. Sind alles extra Klassen.
unions sind immer so eine Sache. Vorsichtig sein.
https://www.heise.de/developer/artikel/C-Core-Guidelines-Regeln-fuer-Unions-3893493.html

Ja, das type punning...
Der C++ Standard spricht von Undefined.
Die GCC allerdings garantiert das Verhalten.
Was alleine natürlich nicht alle Fußangeln unterbindet.

Hallo,

okay, "die Garantie" im gcc kannte ich noch nicht. Dann ist es etwas entschärft wurden.
Ansonsten habe ich zum Thread Thema leider keine bessere Idee.

Hier noch mal aufgepeppt, mit dem im Artikel genannten tagging.
Damit dürfte es jetzt vollständig Typesicher sein.

#include <Streaming.h> // die Lib findest du selber ;-)
Print &cout = Serial; // cout Emulation für "Arme"


enum class Grund:byte
{
  a = 1,
  b = 2,
};

enum class Extend1:byte
{
  d = 3,
  e = 4,
};

enum class Extend2:byte
{
  d = 4,
  e = 5,
};

template <typename EnumA, typename EnumB>
class EnumMerge
{
  private:
  union
  { 
    EnumA enuma;
    EnumB enumb;
    byte asByte;
  };
  enum :byte{a,b} tag;

public:
 // EnumMerge() {};
  EnumMerge(EnumA e): enuma(e),tag(a){}
  EnumMerge(EnumB e): enumb(e),tag(b){}
  operator EnumA() 
  { 
    if(tag!=a) abort();
    return enuma;
  }
  operator EnumB() 
  { 
    if(tag!=b) abort();
    return enumb;
  }
  operator byte()  { return asByte;}
};


using MyEnum1 = EnumMerge<Grund,Extend1>;
using MyEnum2 = EnumMerge<Grund,Extend2>;

void test1(MyEnum1 value){(void)value;}
void test2(MyEnum2 value){(void)value;}



void setup() 
{
  Serial.begin(9600);
  cout << F("Start: ") << F(__FILE__) << endl;
  cout << byte(MyEnum1(Grund::a)) << endl;
  
  test1(MyEnum1(Grund::a));
  test1(MyEnum1(Extend1::d));
  //test1(MyEnum1(Extend2::d));
  
  //test2(MyEnum1(Grund::a));
  //test2(MyEnum1(Extend1::d));
  //test2(MyEnum1(Extend2::d));

  //test1(MyEnum2(Grund::a));
  //test1(MyEnum2(Extend1::d));
  //test1(MyEnum2(Extend2::d));
  
  test2(MyEnum2(Grund::a));
  //test2(MyEnum2(Extend1::d));
  test2(MyEnum2(Extend2::d));

   cout << F("tests done ") << endl;
}

void loop() 
{

}

Natürlich wäre es besser eine Exception zu werfen, als den abort() einzusetzen.
Aber leider haben wir das Gewehr nicht überall.

Die auskommentierten test Zeilen, werfen übrigens allesamt Errors wegen Type Mismatch

Genau. Wäre vieles leichter und klarer mit Exceptions.

Im arduino-esp32 Core ab 2.0.x gibt es übrigens jetzt Exceptions:
Link

Kleiner Nachteil meiner Singleton-Lösung: die FunctionCode-Instanzen kann man nicht als case-Bedingung im switch benutzen, weil sie zwar const, aber zur Compilezeit eben keine Konstanten sind. Da muss ich dann doch den Umweg über die uint8_t-Konvertierung nehmen.

Hallo,

Vielleicht eine Folge von einer Spur zu viel copy & paste. :wink:

Verwendetes using und Funktionsname ändern.
Bsp.

  test1(MyEnum1(Grund::a));
  test1(MyEnum1(Extend1::d));
  test2(MyEnum2(Extend2::d));

  test1(MyEnum1(Grund::a));
  test1(MyEnum1(Extend1::d));
  test2(MyEnum2(Extend2::d));

  test2(MyEnum2(Grund::a));
  test1(MyEnum1(Extend1::d));
  test2(MyEnum2(Extend2::d));
  
  test2(MyEnum2(Grund::a));
  test1(MyEnum1(Extend1::d));
  test2(MyEnum2(Extend2::d));

Naja, meine Tests sollen testen.

Dafür benötige ich nicht nur positive Ergebnisse, sondern bei falschen Parametern auch negative Ergebnisse. Die negativen Ergebnisse sind mindestens ebenso wichtig/notwendig, wie die positiven.

Drum spielen meine Test auch alle relevanten Kombinationen durch.
Keine Duplikate, also auch kein blindes Copy&Paste.
Sondern mit Sinn und Verstand, oder?

Hallo,

ach so, ich dachte du hättest dich gewundert warum der Compiler meckert.

Ja, nee...

Hab das nur fix aus meiner Wühlkiste gefischt.
Dabei vergessen/übersehen, nicht beachtet, dass die/alle Tests noch drin sind.

Meist/Oft investiere ich mehr Arbeit/Aufwand in den Test von Komponenten, als in den eigentlichen Bau dieser. Die Reste davon siehst du in dem Programm.

War keinesfalls um irgendjemanden, z.B. dich, zu verwirren.

Ich habe die Idee inzwischen wieder in die Tonne getreten. Dadurch, dass die Klasse kein integraler Datentyp ist, handele ich mir mehr Ärger ein, als guttut. Bleibt es halt beim enum.

Ja, sowas habe ich schon fast erwartet.
Ich glaube, das ist die richtige Entscheidung.

Deinen Code konnte ich nicht wirklich verbessern.
Wollte ihn darum auch nicht kritisieren.
Denn wer kritisiert, sollte es schon besser machen können.
Die Singleton Eigenschaft, hat mich wohl am meisten gestört.

Bleibt es halt beim enum.

Hat immerhin den Vorteil, dass man Dutzende, beliebig große, davon anlegen kann ohne Speicher zu verbrauchen.

Für weitere Vorschläge reichts bei mir leider nicht.
Dafür habe ich noch Zuwenig davon verstanden, was du da vor hast.

1 Like

Hallo,

man kann als Lib Schreiber laut meiner Meinung sowieso nur die offiziellen Standard Codes verwenden. Alles andere wäre nicht mehr universell und nur auf eine bestimmte Hardware bezogen. Ich vergleiche das mit ASCII Code und Displays. Außerhalb vom Standardcode interpretieren Displays den ASCII Code individuell. Da hilft nur ein Blick ins Datenblatt oder man schreibt sich einen Testcode.

Während ich das tippe hätte ich noch eine Idee. Kann gut sein, muss aber nicht.
Du erstellst eine Tabelle mit Standard Code und dann weitere extra Tabellen die Hardware spezifisch sind. Beim Objekt anlegen gibt es einen Parameter für die verwendete Hardware. Damit wird abhängig der spezifische Code zum Standardcode zur Verfügung gestellt. Könnte man per Logik Vergleich oder abhängiges Inklude machen. Dann kannst du bei enum bzw. struct bleiben und kombinieren.

Falls du auch C++20 zur Verfügung hast, was ich denke, und vielleicht verschiedene enums logisch kombinieren möchtest vor einer Zuweisung, dann wirst du deprecated Warnungen erhalten. Unterschiedliche enums "Klassen" sollen nicht mehr vermischt werden. Also das was mit enum class nicht mehr funktioniert soll auch mit enum ohne class gelten. Wer es doch machen möchte muss vorher auf byte casten damit die Warnung weg ist. Ich würde wenn ich neuen Code schreibe müßte struct verwenden. Unter der Annahme falls man unterschiedliche enums überhaupt verknüpfen möchte. Wenn nicht könnte man enums verwenden. Obwohl ich auch hier eher der struct Typ wäre. Oder constexpr Variablen im namespace. Müßte man sich überlegen.