Iniezione di dipendenza tramite Costruttori vs Classi astratte

0

Negli ultimi giorni ho svolto ricerche sul rapporto tra classi astratte e dipendenza immesso tramite le classi del costruttore.

Sembra che ogni volta che posso avere una classe iniettata dalla dipendenza:

public interface IServiceC
{
    void methodC();
};

public class ServiceC implements IServiceC
{
    ServiceC(IServiceA a, IServiceB b) { .. }

    void methodC() { 
      //..
      a.methodA(); 
      //.. 
      b.methodB();
    }
};

A un certo punto dovrò fornirlo con istanze dire instanceA, instanceB.

Ma non è come avere una classe astratta e una classe concreta usando le istanze?:

public abstract class AServiceC implements IServiceC 
{
    abstract void methodA();
    abstract void methodB();

    void methodC() {
       //...
       serviceA();
       //...
       serviceB();
    }
}

public class CServiceC extends AServiceC
{
    CServiceC(ServiceAArgs argsa, ServiceBArgs argsb)
    {
        instanceA = new CServiceA(argsa);
        instanceB = new CServiceB(argsb);
    }

    void methodA() { instanceA.methodA(); }
    void methodB() { instanceB.methodB(); }

    CServiceA instanceA;
    CServiceB instanceB;
};

Tutto ciò presuppone che conosciamo le classi concrete, ad esempio abbiamo solo test e versioni di rilascio. Il codice di rilascio conosce le istanze concrete mentre il codice di test implementa semplicemente le interfacce appropriate.

Le classi astratte possono quindi inoltrare solo il livello inferiore tramite il costruttore e gestire l'impianto idraulico.

Mi sto perdendo qualcosa di cruciale qui? Sto provando ad applicare DI per un firmware incorporato scritto in C in cui devo avere tutto sistematicamente assegnato. Ciò significa che ho bisogno di conoscere le istanze concrete prima di passarle a un altro oggetto che le ha come dipendenza. Attualmente sto usando i callback invece di accettare le interfacce nel costruttore che è fondamentalmente lo stesso delle interfacce a metodo singolo ma più guardo questo le classi più astratte sembrano utili dato che di solito è necessario possedere i componenti di livello inferiore quando si scrive un livello più alto -un livello.

    
posta Liarokapis Alexandros 15.11.2018 - 00:04
fonte

3 risposte

1

Penso che tu abbia perso il punto dietro Iniezione delle dipendenze che è Inversione di dipendenza .

In particolare, il chiamante non conosce la reale implementazione del servizio, questo richiede l'indirezione tramite virtual (in C ++) o function pointers (C).

Ha diversi vantaggi, in particolare con i test in quanto i collaboratori possono essere un doppio test di qualche tipo. Fornisce inoltre una certa flessibilità spostando le decisioni dalla parte che fa il codice alla parte di configurazione del codice che ne sa di più sul comportamento desiderato più ampio.

Nel caso del tuo oggetto astratto, l'implementazione (l'oggetto costruito reale) conosce le implementazioni delle sue collaborazioni, perché le costruisce in modo specifico. Riesce a raggiungere la condivisione logica (tramite la classe base), ma non è possibile scambiare uno di questi due collaboratori per nessuna ragione senza che cambi per tutti, o duplicando la classe derivata. Questo design soffre anche di cambiamenti, poiché le implementazioni influiscono sulla classe base e la classe base ha effetto su tutte le sue implementazioni.

Questo ha davvero bisogno di un esempio. Utilizzerò un programma di compressione: il caso d'uso di base è: legge un file, lo comprime e lo scrive su un altro file.

Dato che la maggior parte dei compilatori C sono anche compilatori C ++, scriverò principalmente C, ma usando alcune caratteristiche del C ++, quindi il sotto è C +.

int read_file(handle h, char* buffer, int length);
int write_file(handle h, char* buffer, int length);

int compress_file(handle from, handle to)
{
    handle h = //obtained by opening file
    const int N = //some length.
    char buffer[N];
    int k;

    while(0 != (k = read_file(from, buffer, N)))
    {
        //compression logic here
        ...
        //buffer and k now represent the compressed data.

        if (k != write_file(to, buffer, k))
        {
            return -1; //failed report some error code
        }
    }

    return 0; //some success code, or maybe the compressed length.
}

int main()
{
    //open handles

    if (0 != compress_file(from, to))
    {
         //handle error
    }

    //cleanup
}

Fatto bene.

Oops, i requisiti sono cambiati e ora dobbiamo comprimere un file in una rete. Sfortunatamente dobbiamo usare un'API diversa.

class NetworkPoint
{
   void write_byte(char a);
}

Quindi come fai a evitare questo? Un'opzione è spingere la logica per la compressione. È probabilmente la parte più complicata di questo sistema, nessun punto di riscrittura.

int compress_buffer(char* buffer_in, int N, int* n, char* buffer_out, int K, int* k)
{
     //logic about compression here
}

compress_file non cambia molto e ora c'è una seconda versione per coprire il nuovo caso d'uso della compressione sulla rete.

int compress_file(handle from, handle to)
{
    handle h = //obtained by opening file
    const int N = //some length.
    char buffer[N];
    int k;

    while(0 != (k = read_file(from, buffer, N)))
    {
        compress_buffer(...);

        if (k != write_file(to, buffer, k))
        {
            return -1; //failed report some error code
        }
    }

    return 0; //some success code, or maybe the compressed length.
}
int compress_network(handle from, NetworkPoint& to)
{
    handle h = //obtained by opening file
    const int N = //some length.
    char buffer[N];
    int k;

    while(0 != (k = read_file(from, buffer, N)))
    {
        compress_buffer(...);

        for (int i = 0; i < k; ++i)
        {
            to.write(buffer[i]);
        }
    }

    return 0; //some success code, or maybe the compressed length.
}

Questa è la duplicazione del codice.

Se è necessario un altro end-point, questo aggiungerà un altro duplicato. Se la logica aziendale fondamentale cambia, ci sono due (o più) posti che devono essere aggiornati. Chiaramente questo va bene in alcuni casi, ma in altri questo andrà rapidamente fuori controllo.

Quindi, di cosa ha realmente bisogno la logica di business "compressa"? Ha bisogno di "ottenere dati", preferibilmente in un buffer, quindi deve fare un po 'di "compressione", quindi deve "inviare dati" che è stato appena compresso. Risulta che ogni "" è un servizio.

class Reader
{
    virtual int read(char* buffer, int N)=0;
}
class Writer
{
    virtual int write(char* buffer, int N)=0;
}
class Compressor
{
    virtual int compress(char* buffer_in, int N, int* n, char* buffer_out, int K, int* k)=0;
}

Per essere chiari, un'interfaccia non dovrebbe mai avere funzioni implementate, né dovrebbe avere alcun membro di dati.

Se trovi che alcune strutture ripetitive che implementano una classe astratta per alcune delle implementazioni del servizio potrebbero funzionare, basta non inquinare la definizione dell'interfaccia stessa.

Ciò consente di compartimentare tale comportamento.

class FileWriter : public Writer
{
    int handle;

    ...implement to write to a file handle...
}
class NetworkWriter : public Writer
{
    NetworkPoint point;

    ...implement to write to a NetworkPoint...
}

E ora la dipendenza diventa invertibile.

int compress_data(Reader& from, Compressor& compress, Writer& to)
{
    handle h = //obtained by opening file
    const int N = //some length.
    char buffer[N];
    int k;

    while(0 != (k = from.read(from, buffer, N)))
    {
        compress.compress(...);

        if (k != to.write(to, buffer, k))
        {
            return -1; //failed report some error code
        }
    }

    return 0; //some success code, or maybe the compressed length.
}

Se abbiamo bisogno di un nuovo tipo di lettore, scrittore o algoritmo di compressione, questa logica di business non interessa. Basta implementare il servizio e inoltrarlo.

Ora abbiamo già in corso l'iniezione di dipendenza. Questo è ciò che sono quei parametri di funzione. Il chiamante "inietta" le dipendenze in questa funzione. La funzione non ha il controllo diretto di tali servizi, ma può interagire con essi.

Ora consente di presumere che questa logica aziendale sia di per sé un servizio configurabile, diciamo che dovrebbe essere eseguita da un task manager.

class Task
{
   virtual void execute()=0;
}

class CompressionTask
{
   std::shared_ptr<Reader> reader;
   std::shared_ptr<Compressor> compressor;
   std::shared_ptr<Writer> writer;

   CompressionTask(std::shared_ptr<Reader> reader, std::shared_ptr<Compressor> compressor, std::shared_ptr<Writer> writer)
       : reader(reader), compressor(compressor), writer(writer)
   {}

   void execute()
   {
       compress_data(*reader, *compressor, *writer);
   }
}

CompressionTask accetta diverse dipendenze iniettate, non gliene importa troppo, ma richiede alcune garanzie di durata (shared_ptr). Queste dipendenze sono nascoste dal TaskManager e, a parte la loro funzione, l'implementazione è nascosta alla logica aziendale.

Qualcuno da qualche parte dovrà creare l'oggetto CompressionTask e dovrà conoscere abbastanza le dipendenze per crearle, richiederle o passarle. Questo pezzo di codice dovrebbe tuttavia avere tutte le informazioni disponibili per prendere tali decisioni. Il resto della base di codice può ignorarlo, trattandolo come un collaboratore generico o una fonte di dipendenze.

Invertire le dipendenze sopra ottenute:

  • Fonti di dati comprimibili multiple
  • Sink di dati comprimibili multipli
  • Algoritmi di compressione multipli
  • Deduplicazione della logica aziendale
  • Nascondere le decisioni di configurazione dalla più grande base di codice
  • Spostare le decisioni di configurazione in un unico posto erano le conoscenze necessarie a rendere questa decisione
  • Resistenza ridotta alle modifiche dei requisiti
risposta data 15.11.2018 - 01:56
fonte
1

I am trying to applying DI for an embedded firmware written in C in which I must have everything statically allocated. This means that I need to know the concrete instances before passing them to another object that has them as a dependency.

Ho qualche problema a seguire la tua discussione sull'iniezione di dipendenza rispetto alle classi astratte dato che il tuo codice è scritto in C, e quindi non ha comunque nozioni di classi astratte o interfacce in quanto tali. Detto questo, è ancora possibile emulare DI in C definendo le strutture che contengono i puntatori di funzioni con i metodi necessari. Per il tuo esempio:

IServiceC.h

#ifndef ISERVICE_C_ISERVICE_C_H
#define ISERVICE_C_ISERVICE_C_H

typedef struct IServiceC IServiceC;
struct IServiceC {
    void (*methodC)(void* _this);
};

void IServiceC_methodC(void* _this);

#endif

IServiceC.c

#include "IServiceC.h"

void IServiceC_methodC(void* _this) {
  IServiceC* self = (IServiceC*)_this;
  self->methodC(self);
}

ServiceC.h (simile a ServiceA e ServiceB)

#ifndef SERVICE_C_SERVICE_C_H
#define SERVICE_C_SERVICE_C_H

#include "../ServiceA/IServiceA.h"
#include "../ServiceB/IServiceB.h"
#include "IServiceC.h"

typedef struct ServiceC ServiceC;
ServiceC* initServiceC(IServiceA* a, IServiceB* b);

#endif

ServiceC.c

#include <stdlib.h>
#include <stdbool.h>
#include "ServiceC.h"

struct ServiceC {
    IServiceC base;
    IServiceA* a;
    IServiceB* b;
    bool initialized;
};

static void methodC(void* _this) {
    ServiceC* self = (ServiceC*) _this;
    //..
    IServiceA_methodA(self->a);
    //.. 
    IServiceB_methodB(self->b);
}

// if you have a fixed upper bound on how many you need,
// then make this an array and have initServiceC act like
// a simple allocator.
static struct ServiceC _instance = { { .methodC = methodC }, NULL, NULL, false };

ServiceC* initServiceC(IServiceA* a, IServiceB* b) {
    if (!_instance.initialized) {
        _instance.a = a;
        _instance.b = b;
        _instance.initialized = true;
    } else {
        // TODO: throw a warning?
    }
    return &_instance;
}

main.c

#include "ServiceA/ServiceA.h"
#include "ServiceB/ServiceB.h"
#include "ServiceC/ServiceC.h"

int main() {
    IServiceA* a = (IServiceA*)initServiceA();
    IServiceB* b = (IServiceB*)initServiceB();
    ServiceC* c = initServiceC(a,b);
    IServiceC_methodC(c);
    return 0;
}

Lo standard C garantisce ( C11 6.7.2.1 §13 ) che puoi lanciare un puntatore-a-una-struttura in un puntatore alla prima voce in quella struttura, quindi qualsiasi ServiceC* può essere castato in modo sicuro su IServiceC* . Di conseguenza, questo approccio è limitato all'ereditarietà singola, comprese le interfacce.

Puoi sfogliare il codice per questa risposta (insieme ai Servizi A e B) su repl.it, qui .

Da qui puoi vedere che diverse implementazioni di servizio delle stesse interfacce sarebbero altrettanto facilmente sostituibili nella chiamata a initServiceC in main . In nessun punto qui è stata allocata alcuna memoria allocata dinamicamente (tranne, forse, nell'implementazione puts ) per o dai servizi. Come ci si aspetterebbe, l'interfaccia IServiceC non è a conoscenza dell'esistenza di A o B.

    
risposta data 15.11.2018 - 02:02
fonte
1

TL; DR

Quando utilizziamo new nel costruttore, stiamo codificando con forza una dipendenza.

Quando accettiamo un oggetto nel costruttore (invece di chiamare new ) stiamo permettendo alla classe di essere configurata dal chiamante.

Stai spingendo la configurazione di Concrete-Service-C nel suo costruttore. Bene, questa configurazione deve andare da qualche parte, ovviamente - tuttavia, con l'iniezione della dipendenza: che può andare al di fuori della classe; mentre senza DI: c'è una solida dipendenza. Gli approcci di iniezione delle dipendenze cercano di dare un calcio a questo problema di configurazione al piano di sopra; mentre, quello che stai facendo richiede classi separate per test e sviluppo che duplicano l'approccio di costruzione (dei sottotipi specifici) da argomenti (comuni).

Tutto ciò che in realtà si riduce è che le classi sono un po 'più fragili nell'ultimo approccio: un test semplice non può usare un test CServiceA con un CServiceB reale; ciò richiederebbe un'altra classe piuttosto che un altro test.

    
risposta data 15.11.2018 - 03:12
fonte