dimanche 27 décembre 2020

What are recommended design pattern(s) to add functionality to a base class depending on how it's derived

I'm looking for feedback/recommendations/best-practices to accomplish the following architecture in the most elegant, expressive, and extensible fashion. I've tried a few approaches, including multiple inheritance and composition OOP as well as a "flat" classless C-like approach, but all require boilerplate and repetitive code which seems "smelly" to me. I'm convinced there should be a better way. What other design patterns should I consider? Ways to use templates and static typing? Other ways to achieve the necessary polymorphism?

Problem Statement:

"Device" objects can have different "Capabilities" which are supported depending on the concrete type of the device. Each type of concrete device's supported capabilities is known at compile time, so there is no need to assign different capabilities at run-time. Capabilities don't need to interact with other capabilities that any given device supports.

ConcreteDevices (A,B, & C in example below) inherit the BaseDevice class. BaseDevice contains functionality common to all devices. BaseDevice is also the interface class on which a client will invoke a DoCapability_N_() method, possibly without knowing the exact type of ConcreteDevice with which they are interacting (e.g. via a collection of devices stored in a std::list<BaseDevice*>). Run-time polymorphism required here.

If the ConcreteDevice has been "assigned" the N capability, the DoCapability_N_() method should be delegated to the associated ImplCapability_N_ class, otherwise the fallback DoCapability_N_() method from BaseDevice should be called, indicating that capability hasn't been assigned to this device.

The Capability implementations, ImplCapability (1,2, & 3 in the example below), inherit common functionality from the BaseCapability class. These implementations also need access to the BaseDevice's public members in order to perform their work.

Here is some basic "starter" code which isn't fully functional but expresses the basic class design. Comments indicate the missing pieces and possible approaches:

class BaseCapability {
public:
    void* mCapabilityHandle{ nullptr };
    int mBaseCapabilityData{ 0 };
};

class ImplCapability1 : public BaseCapability {
public:
    // QUESTION: how to make sure this method gets called if invoked via a device which has been assigned this capability?
    // QUESTION: how to make sure this method can access public members of BaseDevice? (dependency injection messy; not shown)
    void DoCapability1() { cout << "Doing Capability1!\n"; }

    ImplCapability1() { cout << "Capability1 assigned.\n"; }
};

class ImplCapability2 : public BaseCapability {
public:
    void DoCapability2() { cout << "Doing Capability2!\n"; }

    ImplCapability2() { cout << "Capability2 assigned.\n"; }
};

class ImplCapability3 : public BaseCapability {
public:
    void DoCapability3() { cout << "Doing Capability3!\n"; }

    ImplCapability3() { cout << "Capability3 assigned.\n"; }
};

class BaseDevice {
public:
    std::unique_ptr<ImplCapability1> Cap1Ptr{ nullptr };    // use for composition, construct in ConcreteDevice as necessary
    std::unique_ptr<ImplCapability2> Cap2Ptr{ nullptr };    // use for composition
    std::unique_ptr<ImplCapability3> Cap3Ptr{ nullptr };    // use for composition

    void* mDeviceHandle;
    int mBaseDeviceData{ 42 };

    // default functions to invoke if no capabilities have been assigned to the ConcreteDevice 
    void DoCapability1() { cout << "Capability not assigned.\n"; }
    void DoCapability2() { cout << "Capability not assigned.\n"; }
    void DoCapability3() { cout << "Capability not assigned.\n"; }
};

// QUESTION:  how to "assign" supported capabilities here to each type of concrete devices?...

class ConcreteDeviceA : public BaseDevice, public ImplCapability1, public ImplCapability3 {  // MULTIPLE INHERITANCE?
public:
    // needs Capability1, Capability3
    ConcreteDeviceA() { cout << "DeviceA constructed.\n\n"; };
};

class ConcreteDeviceB : public BaseDevice {
public:
    // needs Capability2
    ConcreteDeviceB() { 
        Cap2Ptr = std::make_unique<ImplCapability2>();      // COMPOSITION?
        cout << "DeviceB constructed.\n\n"; };
};

class ConcreteDeviceC : public BaseDevice {
public:
    // needs Capability1, Capability2, Capability3
    // .. some other method?
    ConcreteDeviceC() { cout << "DeviceC constructed.\n\n"; };
};

And desired use:

int main()
{
    // create devices
    std::unique_ptr<BaseDevice> DeviceA = std::make_unique<ConcreteDeviceA>();      // has capabilities: 1, 3
    std::unique_ptr<BaseDevice> DeviceB = std::make_unique<ConcreteDeviceB>();      // has capabilities: 1
    std::unique_ptr<BaseDevice> DeviceC = std::make_unique<ConcreteDeviceC>();      // has capabilities: 1, 2, 3
    std::cout << '\n';

    // test capability assignments by dereferencing pointer to base
    DeviceA->DoCapability1();           // should do Capability 1
    DeviceA->DoCapability2();           // should indicate not assigned
    DeviceA->DoCapability3();           // should do Capability 3
    std::cout << '\n';
    DeviceB->DoCapability1();           // should indicate not assigned
    DeviceB->DoCapability2();           // should do Capability 2
    DeviceB->DoCapability3();           // should indicate not assigned
    std::cout << '\n';
    DeviceC->DoCapability1();           // should do Capability 1
    DeviceC->DoCapability2();           // should do Capability 2
    DeviceC->DoCapability3();           // should do Capability 3
}

As for why I have a feeling that neither Multiple Inheritance nor Composition are the most elegant approach:

Composition:

  • having to write pass-thru functions which "wrap" the ImplCapability's DoCapability_N_() method within the BaseDevice's DoCapability_N_() method
  • having to explicitly check if a Capability has been assigned via testing if(CapNPtr != nullptr)...
  • passing of injected dependencies in constructors and correspondingly longer initialization lists
  • uses run-time polymorphism when compile-time should suffice for the ConcreteDevice to ImplCapability relationships

Multiple inheritance:

  • injected dependencies with lots of parent class constructors and correspondingly longer initialization lists
  • less extensible: what if in the future we wanted to give DeviceX two different Capability1's (with slightly different parameterization)? We would have to create a new "ImplCapability4" and copy a bunch of code.
  • uses run-time polymorphism when compile-time should suffice for the ConcreteDevice to ImplCapability relationships

To summarize:

What is the recommended strategy or design pattern to achieve this "assignment" of capabilities to devices? -> Multiple inheritance? Composition? Template-based approaches (mixins, CRTP), Other?

What is the recommended strategy to achieve the polymorphism that automatically selects the ImplCapability's DoCapability_N_() method if that capability has been assigned to the device?
-> Virtual functions & overriding? Templates? Overloading? Other?

What is the recommended strategy to insure the ImplCapability classes can access the BaseDevice members? -> Inheritance? Dependency injection? Other?

Aucun commentaire:

Enregistrer un commentaire