Usando sottoclassi vuote da aggiungere alla semantica di una gerarchia di classi?

7

Mi chiedevo se utilizzare (quasi) le classi derivate vuote per dare semantica aggiuntiva a una gerarchia di classi fosse una buona idea (o pratica)?

Poiché un esempio (non così breve) è sempre meglio di un discorso lungo, supponiamo di scrivere una gerarchia di classi per la generazione SQL. Vuoi rappresentare un comando di manipolazione dei dati (INSERT, UPDATE o DELETE), quindi hai due alternative:

Prima versione, con istruzione switch

enum CommandType {
    Insert,
    Update,
    Delete
}

class SqlCommand {
    String TableName { get; }
    String SchemaName { get; }
    CommandType CommandType { get; }
    IEnumerable<string> Columns { get; }
    // ... and so on
}

class SqlGenerator  {
    string GenerateSQLFor(SqlCommand command)
    {
        switch (command.CommandType) 
        {
            case CommandType.Insert:
                return generateInsertCommand(command);
            case CommandType.Update:
                return generateUpdateCommand(command);
            case CommandType.Delete:
                return generateDeleteCommand(command);
            default:
                throw new NotSupportedException();
        }
    }
}

Seconda versione con VisitorPattern (nota che la domanda NON riguarda l'utilizzo del pattern Visitor o l'aggiunta di nuove operazioni)

abstract class SqlCommand {
    String TableName { get; }
    String SchemaName { get; }
    IEnumerable<string> Columns { get; }
    // ... and so on
    abstract void Accept(SqlCommandVisitor visitor);
}

class InsertCommand : SqlCommand 
{
    overrides void Accept(SqlCommandVisitor visitor) 
    {
        visitor.VisitInsertCommand(this);
    }
}

class DeleteCommand : SqlCommand 
{
    overrides void Accept(SqlCommandVisitor visitor) 
    {
        visitor.VisitDeleteCommand(this);
    }
}

class SqlCommandVisitor  {
    void InitStringBuffer() { ... }
    string GetStringBuffer() { ... }

    string GenerateSQLFor(SqlCommand command)
    {
        InitStringBuffer();
        command.Accept(this);
        return GetStringBuffer();
    }

    void Visit(SqlCommand command) { ... }
    void VisitInsertCommand(InsertCommand command) { ... }
    void VisitDeleteCommand(DeleteCommand command) { ... }
    void VisitUpdateCommand(UpdateCommand command) { ... }
}

Con entrambi gli esempi otteniamo lo stesso risultato, ma:

  • Nella prima versione, il mio codice sembra più SECCO, anche se il polimorfismo è generalmente preferibile rispetto a un'istruzione switch.
  • Nella seconda versione, mi sento come se stavo inutilmente derivando SqlCommand solo perché il codice abbia più significato.

Un altro caso simile deriva una classe da una classe generica, solo per dare un significato addizionale alla raccolta, ad esempio:

class CustomerList : List<Customer> { }

Non mi chiedo se sia una buona idea. Ci sono delle insidie nel farlo?

Personalmente preferisco la seconda versione esattamente perché aggiunge significato al codice e mi aiuta quando leggo il mio codice più tardi ... Ma mi manca chiaramente l'esperienza per vedere gli svantaggi di questo modello.

    
posta T. Fabre 17.07.2012 - 12:50
fonte

6 risposte

1

Il tuo secondo esempio ha il vantaggio di nessuna istruzione case e nessun CommandType. Ciò significa che è possibile un nuovo tipo di istruzione senza dover modificare un'istruzione case. Una dichiarazione di un caso dovrebbe indurre un programmatore OOP a sospendere un momento e valutare se il progetto debba essere modificato per eliminare la dichiarazione del caso. Una variabile che denoti il tipo di una cosa dovrebbe far sì che il programmatore si fermi a lungo.

Tuttavia, il modello di visitatore non sembra essere necessario qui. Più semplice sarebbe utilizzare una gerarchia di classi, come hai fatto tu, ma poi inserire la generazione SQL all'interno di quella gerarchia piuttosto che avere una classe separata:

class SqlCommand {
  abstract String sql;
}

class InsertCommand : SqlCommand {
  overrides String sql {...};
}

class DeleteCommand : SqlCommand {
  overrides String sql {...};
}

Il codice all'interno delle funzioni sql probabilmente avrà un comportamento condiviso, o abbastanza complessità, che spostare il comportamento e la complessità condivisi in una classe SqlBuilder separata sarebbe positivo:

class InsertCommand : SqlCommand {
  overrides String sql {
    builder = SqlBuilder.new
    builder.add("insert into")
    builder.add_identifier(table_name)
    buidler.add("values")
    builder.add_value_list(column_names)
    ...
    return builder.sql
  };
}

Lo SqlCommand guida il processo di creazione di sql, spingendone i dettagli in SqlBuilder. Ciò mantiene SqlCommand e SqlBuilder disaccoppiati, poiché gli SqlCommands non devono esporre l'intero stato per il builder da utilizzare. Questo ha i vantaggi del tuo secondo esempio, ma senza la complessità del modello di visitatore.

Se si scopre che a volte hai bisogno di sql built in un modo e talvolta in un altro (forse vuoi supportare più di un back-end SQL), quindi estrai la classe SqlBuilder, in questo modo:

class SqlBuilder {
  abstract void add(String s);
  abstract void add_identifier(String identifier);
  abstract void add_value_list(String[] value_list);
  ...
}

class PostgresSqlBuilder : SqlBuilder {
  void add(String s) {...}
  void add_identifier(String identifier) {...}
  void add_value_list(String[] value_list) {...}
  ...
}

class MysqlSqlBuilder : SqlBuilder {
  void add(String s) {...}
  void add_identifier(String identifier) {...}
  void add_value_list(String[] value_list) {...}
  ...
}

Quindi le funzioni sql usano il builder passato a loro, piuttosto che crearne uno:

class InsertCommand : SqlCommand {
  overrides String sql (SqlBuilder builder) {
    builder.add("insert into")
    builder.add_identifier(table_name)
    buidler.add("values")
    builder.add_value_list(column_names)
    ...
    return builder.sql
  };
}
    
risposta data 25.08.2012 - 16:19
fonte
6

Preferisco il primo, ma forse sono solo io - mi piace ESSERE, e mi piace mantenere le cose semplici, e non c'è nulla di più semplice di un'istruzione switch per questo. (Penso sempre che, a meno che tu non abbia una buona ragione per rifattorizzare il passaggio in una classe polimorfa, questo refactoring è principalmente per mostrare quanto può essere intelligente il codice).

Stai scrivendo molto più codice per ottenere lo stesso risultato, e ciò richiede molto lavoro di copia e incolla - e trovo che la codifica di c & p sia un posto in cui i bug hanno più probabilità di apparire. Penso anche che provare a leggere il codice sia più difficile in quanto passa attraverso tutte quelle classi e questi metodi. Ma alla fine, è solo una questione di gusti: entrambi fanno la stessa cosa.

Per la tua seconda domanda .. dipende, una volta derivata da una classe che sei responsabile di controllare tutti i lavori idraulici. Ad esempio, non si derivano da classi di contenitore stl perché non hanno distruttori virtuali e quindi non sono progettate per l'ereditarietà. A meno che non si conoscano le potenziali insidie della classe da cui si eredita, è molto più sicuro contenere la classe base.

L'altro motivo è solo la semantica, una CustomerList non è un elenco di clienti, quindi qualcuno che legge il tuo codice dovrebbe pensare "è uguale a un elenco < > o hanno modificato la funzionalità?" Se non apporti alcuna modifica, lasciala come tipo sottostante oppure usa un'istruzione typedef / using che dice almeno "Ho rinominato questo per sola leggibilità".

    
risposta data 17.07.2012 - 13:35
fonte
5

Lo svantaggio di avere più classi nella gerarchia è che quando devi eseguire il debug delle cose è molto più complicato capire cosa sta succedendo. Più è profonda la tua gerarchia di classi e più livelli hanno il codice vero, più difficile è capire il codice quando fai il debug (perché devi sempre tenere conto di dove ti trovi nella catena di chiamate in qualsiasi momento e di quale oggetto reale sei tu sono, ecc. ecc.)

La vera domanda è: questo rende il tuo codice nel complesso più leggibile? Se avrai solo quell'interruzione in un punto, allora direi che è una vittoria. Considerando che, se dovessi finire per duplicare quell'istruzione switch in tutto il luogo, allora il secondo modulo è probabilmente una vittoria.

    
risposta data 17.07.2012 - 13:47
fonte
1

Usare il sistema dei tipi (in un linguaggio tipicamente statico) per esprimere la semantica è esattamente ciò a cui è destinato. Considera un tipo per gli ID di database che si sottoclassi per ogni tabella, ad esempio un CustomerId, un OrderId e così via. Il vantaggio è che non si sbaglia mai un CustomerId per un OrderId. Ma sono sottoclassi chiaramente vuote. Vedi un Google IO talk su GWT di Ray Ryan intorno alle 7:00 per un esempio.

    
risposta data 25.08.2012 - 17:00
fonte
0

Mi piace di più il secondo approccio poiché quando si guarda in InsertCommand , si legge "C'E 'un comando di inserimento", il che significa che dovrebbero esserci altri comandi. Quando leggi di seguito, vedi questi comandi e capisci perfettamente l'idea.

Il primo approccio indica "C'È UN comando ... con alcuni dettagli in là ...".

    
risposta data 17.07.2012 - 15:47
fonte
-2

No, non è una buona pratica in generale.

Ogni volta che codifichi le informazioni nel sistema di tipi, stai sbagliando (in linguaggi mainstream come C ++, java e C #). Richiede quindi di utilizzare un modello di visitatore o una pila di controlli di tipo per estrarre le informazioni che sono al meglio fragili.

Inoltre, nelle lingue tradizionali rende molto difficile archiviare / modificare tali informazioni. Se per esempio volevi eseguire un comando inserito dall'utente in alcuni IDE SQL.

In breve, se stai memorizzando informazioni - usa solo una variabile; questo è quello per cui sono lì.

    
risposta data 17.07.2012 - 13:07
fonte

Leggi altre domande sui tag