Qual è la vera responsabilità di una classe?

40

Continuo a chiedermi se sia legittimo utilizzare i verbi basati su nomi in OOP.
Mi sono imbattuto in questo articolo brillante , anche se non sono ancora d'accordo il punto che rende.

Per spiegare il problema un po 'di più, l'articolo afferma che non ci dovrebbe essere, ad esempio, una classe FileWriter , ma dal momento che scrivere è un azione dovrebbe essere un metodo della classe File . Ti renderai conto che è spesso dipendente dalla lingua poiché un programmatore di Ruby sarebbe probabilmente contrario all'uso di una classe FileWriter (Ruby usa il metodo File.open per accedere a un file), mentre un programmatore Java non lo farebbe.

Il mio punto di vista personale (e sì, molto umile) è che ciò farebbe rompere il principio di Responsabilità Unica. Quando ho programmato in PHP (perché PHP è ovviamente la lingua migliore per OOP, giusto?), Userei spesso questo tipo di framework:

<?php

// This is just an example that I just made on the fly, may contain errors

class User extends Record {

    protected $name;

    public function __construct($name) {
        $this->name = $name;
    }

}

class UserDataHandler extends DataHandler /* knows the pdo object */ {

    public function find($id) {
         $query = $this->db->prepare('SELECT ' . $this->getFields . ' FROM users WHERE id = :id');
         $query->bindParam(':id', $id, PDO::PARAM_INT);
         $query->setFetchMode( PDO::FETCH_CLASS, 'user');
         $query->execute();
         return $query->fetch( PDO::FETCH_CLASS );
    }


}

?>

Sono a conoscenza del fatto che il suffisso DataHandler non aggiunge nulla di rilevante ; ma il punto è che il principio della singola responsabilità ci dice che un oggetto usato come un modello contenente dati (può essere chiamato un Record) non dovrebbe avere anche la responsabilità di fare query SQL e accesso a DataBase. Ciò in qualche modo invalida il pattern ActionRecord utilizzato ad esempio da Ruby on Rails.

Mi sono imbattuto in questo codice C # (yay, quarto linguaggio oggetto usato in questo post) proprio l'altro giorno:

byte[] bytes = Encoding.Default.GetBytes(myString);
myString = Encoding.UTF8.GetString(bytes);

E devo dire che per me non ha molto senso che una classe Encoding o Charset in realtà codifichi stringhe. Dovrebbe essere semplicemente una rappresentazione di ciò che è veramente una codifica.

Quindi, tenderei a pensare che:

  • Non è una responsabilità della classe File aprire, leggere o salvare i file.
  • Non è responsabilità della classe Xml serializzarsi.
  • Non è responsabilità della classe User interrogare un database.
  • ecc.

Tuttavia, se estrapoliamo queste idee, perché Object ha una classe toString ? Non è responsabilità di una macchina o di un cane convertirsi in una stringa, ora è così?

Comprendo che da un punto di vista pragmatico, eliminare il metodo toString per la bellezza di seguire una forma SOLIDA rigorosa, che rende il codice più gestibile rendendolo inutilizzabile, non è un'opzione accettabile.

Capisco anche che potrebbe non esserci una risposta esatta (che sarebbe più un saggio di una risposta seria) a questo, o che potrebbe essere basata sull'opinione pubblica. Tuttavia, mi piacerebbe ancora sapere se il mio approccio segue effettivamente ciò che è realmente il principio di responsabilità individuale.

Qual è la responsabilità di una classe?

    
posta Pierre Arlaud 04.12.2013 - 10:31
fonte

8 risposte

22

Date alcune divergenze tra le lingue, questo può essere un argomento difficile. Quindi, sto formulando i seguenti commenti in un modo che cerca di essere il più completo possibile nel regno di OO.

Prima di tutto, il cosiddetto "principio di singola responsabilità" è un riflesso - esplicitamente dichiarato - del concetto coesione . Leggendo la letteratura del tempo (intorno al '70), le persone erano (e ancora stanno) lottando per definire cosa sia un modulo e come costruirle in un modo che preservasse le proprietà. Quindi, avrebbero detto "ecco un mucchio di strutture e procedure, ne farò un modulo", ma senza criteri sul perché questa serie di cose arbitrarie siano raggruppate insieme, l'organizzazione potrebbe finire per avere poco senso - poca "coesione". Quindi, sono emerse discussioni sui criteri.

Quindi, la prima cosa da notare qui è che, finora, il dibattito riguarda l'organizzazione e gli effetti correlati sulla manutenzione e la comprensibilità (per poco materiale su un computer se un modulo "ha senso").

Poi, qualcun altro (il Sig. Martin) è entrato e ha applicato lo stesso pensiero all'unità di una classe come criterio da utilizzare quando si pensa a cosa dovrebbe o non dovrebbe appartenere ad esso, promuovendo questi criteri secondo un principio, il uno discusso qui. Il punto che ha fatto è stato che "Una classe dovrebbe avere solo un motivo per cambiare" .

Bene, sappiamo per esperienza che molti oggetti (e molte classi) che sembrano fare "molte cose" hanno una buona ragione per farlo. Il caso indesiderato sarebbero le classi che sono gonfiate di funzionalità al punto da essere impenetrabili al mantenimento, ecc. E capire il secondo è vedere dove mr. Martin stava mirando a quando ha elaborato l'argomento.

Certo, dopo aver letto ciò che il sig. Martin ha scritto, dovrebbe essere chiaro che questi sono criteri di direzione e design per evitare scenari problematici, non in alcun modo per perseguire alcun tipo di conformità, per non parlare di una strong compliance, specialmente quando la "responsabilità" è mal definita (e domande come "questo viola il principio?" sono esempi perfetti della diffusa confusione). Quindi, trovo sfortunato che si chiami un principio , che induca le persone a cercare di portarlo alle ultime conseguenze, dove non servirebbe a nulla. Lo stesso signor Martin ha discusso i progetti secondo cui "fare più di una cosa" dovrebbe essere tenuto in quel modo, dal momento che la separazione avrebbe dato risultati peggiori. Inoltre, ci sono molte sfide conosciute riguardanti la modularità (e questo argomento è un caso), non siamo al punto di avere buone risposte anche per alcune semplici domande a riguardo.

However, if we extrapolate these ideas, why would Object have a toString class? It's not a Car's or a Dog's responsibility to convert itself to a string, now is it?

Ora, lasciatemi fare una pausa per dire qualcosa qui su toString : c'è una cosa fondamentale comunemente trascurata quando si effettua quella transizione del pensiero dai moduli alle classi e si riflette su quali metodi dovrebbero appartenere a una classe. E la cosa è la spedizione dinamica (aka, late binding, "polymorphism").

In un mondo senza "metodi di sovrascrittura", scegliere tra "obj.toString ()" o "toString (obj)" è solo una questione di preferenza per la sintassi. Tuttavia, in un mondo in cui i programmatori possono modificare il comportamento di un programma aggiungendo una sottoclasse con implementazione distinta di un metodo esistente / sovrascritto, questa scelta non è più di gusto: rendendo una procedura un metodo può anche renderlo un candidato per l'override, e lo stesso potrebbe non essere vero per le "procedure libere" (i linguaggi che supportano i multi-metodi hanno una via d'uscita da questa dicotomia). Di conseguenza, non è più solo una discussione sull'organizzazione, ma anche sulla semantica. Infine, a quale classe il metodo è vincolato, diventa anche una decisione che incide (e in molti casi, finora, abbiamo poco più di linee guida per aiutarci a decidere dove le cose appartengono, poiché i compromessi non ovvi emergono da diverse scelte) .

Infine, ci troviamo di fronte a linguaggi che comportano terribili decisioni di progettazione, ad esempio costringendone uno a creare una classe per ogni piccola cosa. Quindi, quello che una volta era la ragione canonica e i criteri principali per avere oggetti (e nella classe-land, quindi, classi) del tutto, cioè avere questi "oggetti" che sono una specie di "comportamenti che si comportano anche come dati" , ma proteggere la loro rappresentazione concreta (se esiste) dalla manipolazione diretta a tutti i costi (e questo è il suggerimento principale per ciò che dovrebbe essere l'interfaccia di un oggetto, dal punto di vista dei suoi clienti), diventa confuso e confuso.

    
risposta data 04.12.2013 - 19:17
fonte
30

[Nota: parlerò degli oggetti qui. Dopotutto, gli oggetti sono ciò che riguarda la programmazione orientata agli oggetti, non le classi.]

Quale è la responsabilità di un oggetto dipende in gran parte dal modello di dominio. Di solito ci sono molti modi per modellare lo stesso dominio e tu sceglierai in un modo o nell'altro in base a come verrà utilizzato il sistema.

Come tutti sappiamo, la metodologia "verbo / sostantivo" che viene spesso insegnata nei corsi introduttivi è ridicola, perché dipende tanto da come si formula una frase. Puoi esprimere quasi tutto come un nome o un verbo, in una voce attiva o passiva. Avere il tuo modello di dominio dipende da quello che è sbagliato, dovrebbe essere una scelta consapevole del design, indipendentemente dal fatto che qualcosa sia un oggetto o un metodo, non solo una conseguenza accidentale di come si formulano i requisiti.

Tuttavia, fa mostra che, proprio come hai molte possibilità di esprimere la stessa cosa in inglese, hai molte possibilità di esprimere la stessa cosa nel tuo modello di dominio ... e nessuna di queste possibilità è intrinsecamente più corretto degli altri. Dipende dal contesto.

Ecco un esempio che è anche molto popolare nei corsi introduttivi di OO: un conto in banca. Che viene in genere modellato come un oggetto con un campo balance e un metodo transfer . In altre parole: il saldo del conto è dati e un trasferimento è un'operazione . Quale è un modo ragionevole per modellare un conto bancario.

Tranne, questo è non come i conti bancari sono modellati nel software bancario reale (e in effetti, non è il modo in cui le banche lavorano nel mondo reale). Invece, si dispone di una polizza di transazione e il saldo dell'account viene calcolato aggiungendo (e sottraendo) tutti i saldi di transazione per un account. In altre parole: il trasferimento è data e il saldo è un'operazione ! (Interessante, questo rende anche il tuo sistema puramente funzionale, dal momento che non hai mai bisogno di modificare il saldo, sia gli oggetti del tuo account che gli oggetti della transazione non devono mai cambiare, sono immutabili.)

Per quanto riguarda la tua domanda specifica su toString , sono d'accordo. Preferisco di gran lunga la soluzione Haskell di una classe Showable type . (Scala ci insegna che le classi di caratteri si adattano magnificamente a OO). Lo stesso vale per l'uguaglianza. L'uguaglianza è molto spesso non una proprietà di un oggetto ma del contesto in cui viene utilizzato l'oggetto. Basti pensare ai numeri in virgola mobile: quale dovrebbe essere l'epsilon? Ancora una volta, Haskell ha la classe di tipi Eq .

    
risposta data 04.12.2013 - 11:23
fonte
8

Troppo spesso il Principio della singola responsabilità diventa un principio di responsabilità zero e si finisce con Classi anemiche che non fanno nulla (tranne setter e getter), che porta a un disastro del Regno dei nomi .

Non lo otterrai mai perfetto, ma è meglio che un corso faccia un po 'troppo di troppo poco.

Nell'esempio con Encoding, IMO, sicuramente dovrebbe essere in grado di codificare. Che cosa dovresti fare invece? Il solo fatto di avere un nome "utf" è una responsabilità zero. Ora, forse il nome dovrebbe essere Encoder. Ma, come ha detto Konrad, i dati (che codificano) e il comportamento (farlo) appartengono insieme .

    
risposta data 07.12.2013 - 01:49
fonte
7

Un'istanza di una classe è una chiusura. Questo è tutto. Se pensi in questo modo tutto il software ben progettato che guardi avrà un aspetto corretto, e tutti i software mal concepiti non lo faranno. Lasciami espandere.

Se vuoi scrivere qualcosa da scrivere in un file, pensa a tutte le cose che devi specificare (al sistema operativo): nomefile, metodo di accesso (leggi, scrivi, aggiungi), la stringa che vuoi scrivere.

Quindi costruisci un oggetto File con il nome file (e il metodo di accesso). L'oggetto File è ora chiuso sul nome del file (in questo caso probabilmente lo avrà preso come valore readonly / const). L'istanza File è ora pronta per ricevere chiamate al metodo "write" definito nella classe. Questo prende solo un argomento stringa, ma nel corpo del metodo write, l'implementazione, c'è anche l'accesso al nome del file (o al gestore di file che è stato creato da esso).

Un'istanza della classe File pertanto inserisce alcuni dati in una specie di blob composito in modo che l'uso successivo di tale istanza sia più semplice. Quando si dispone di un oggetto File, non è necessario preoccuparsi di ciò che è il nome del file, si è solo preoccupati di quale stringa si è inserito come argomento nella chiamata di scrittura. Per ripetere: l'oggetto File racchiude tutto ciò che non è necessario sapere su dove si sta effettivamente recando la stringa che si desidera scrivere.

La maggior parte dei problemi che vuoi risolvere sono stratificati in questo modo: le cose create in primo piano, le cose create all'inizio di ogni iterazione di un qualche tipo di loop di processo, poi diverse cose dichiarate nelle due metà di un if, quindi cose create all'inizio di una sorta di sub-loop, quindi cose dichiarate come parte di un algoritmo all'interno di quel ciclo ecc. Come si aggiungono più e più chiamate di funzione allo stack, si sta facendo sempre un codice più fine, ma ogni volta che si Avremo inscatolato i dati nei livelli più in basso nello stack in una sorta di bella astrazione che ne facilita l'accesso. Guarda cosa stai facendo usando "OO" come una sorta di framework di gestione della chiusura per un approccio di programmazione funzionale.

La soluzione di scelta rapida per alcuni problemi riguarda lo stato mutabile degli oggetti, che non è così funzionale: stai manipolando le chiusure dall'esterno. Stai rendendo alcune delle cose della tua classe "globali", almeno nell'ambito di cui sopra. Ogni volta che scrivi un setter, cerca di evitarli. Direi che è qui che hai la responsabilità della tua classe, o delle altre classi in cui opera, sbagliata. Ma non preoccuparti troppo - a volte è pragmatico mescolare un po 'di stato mutabile per risolvere certi tipi di problemi rapidamente, senza troppo carico cognitivo e in realtà ottenere qualcosa.

Quindi, in sintesi, per rispondere alla tua domanda: qual è la vera responsabilità di una classe? È per presentare una sorta di piattaforma, basata su alcuni dati sottostanti, per ottenere un tipo di operazione più dettagliato. È per incapsulare la metà dei dati coinvolti in un'operazione che cambia meno frequentemente rispetto all'altra metà dei dati (Think currying ...). Per esempio. nomefile vs stringa da scrivere.

Spesso queste incapsulazioni sembrano superficialmente come oggetti del mondo reale / un oggetto nel dominio del problema, ma non lasciatevi ingannare. Quando crei una classe Car, non è in realtà una macchina, è una raccolta di dati che forma una piattaforma sulla quale realizzare determinati tipi di cose che potresti considerare di voler fare su un'auto. Formare una rappresentazione stringa (toString) è una di quelle cose - sputare tutti quei dati interni. Ricorda che a volte una classe di auto potrebbe non essere la classe giusta anche se il dominio del problema riguarda esclusivamente le automobili. Al contrario di Kingdom of nouns, sono le operazioni, i verbi su cui una classe dovrebbe basarsi.

    
risposta data 06.12.2013 - 09:48
fonte
4

I also understand that there may not be an exact answer (which would more be an essay than a serious answer) to this, or that it may be opinion-based. Nevertheless I would still like to know if my approach actually follows what the single-responsibility principle really is.

Mentre è così, potrebbe non essere necessariamente un buon codice. Al di là del semplice fatto che qualsiasi tipo di regola seguita ciecamente porta a un codice errato, si avvia una premessa non valida: gli oggetti (di programmazione) non sono oggetti (fisici).

Gli oggetti sono un insieme di pacchetti coesi di dati e operazioni (e talvolta solo uno dei due). Mentre questi spesso modellano oggetti del mondo reale, la differenza tra un modello computerizzato di qualcosa e quella cosa stessa richiede differenze.

Prendendo quella linea dura tra un "sostantivo" che rappresenta una cosa e altre cose che li consumano, stai andando contro un vantaggio chiave della programmazione orientata agli oggetti (avendo funzione e stato insieme in modo che la funzione possa proteggere gli invarianti di lo stato). Peggio ancora, stai cercando di rappresentare il mondo fisico nel codice, che, come dimostrato dalla cronologia, non funzionerà bene.

    
risposta data 04.12.2013 - 18:29
fonte
4

I came across this C# code (yay, fourth object language used in this post) just the other day:

byte[] bytes = Encoding.Default.GetBytes(myString);
myString = Encoding.UTF8.GetString(bytes);

And I gotta say that it doesn't make much sense to me that an Encoding or Charset class actually encodes strings. It should merely be a representation of what an encoding really is.

In teoria, sì, ma penso che C # faccia un compromesso giustificato per semplicità (al contrario dell'approccio più rigoroso di Java).

Se Encoding rappresenta solo (o identifica) determinate codifiche, ad esempio UTF-8, allora ovviamente avrai bisogno anche di una classe Encoder , in modo che tu possa implementare GetBytes su di esso, ma poi tu è necessario gestire la relazione tra Encoder s e Encoding s, quindi ci ritroviamo con il nostro buon vecchio '

EncodersFactory.getDefaultFactory().createEncoder(new UTF8Encoding()).getBytes(myString)

così brillantemente raggirato in quell'articolo a cui ti sei collegato (ottima lettura, tra l'altro).

Se ti capisco bene, stai chiedendo dove si trova la linea.

Beh, sul lato opposto dello spettro c'è l'approccio procedurale, non difficile da implementare nei linguaggi OOP con l'uso di classi statiche:

HelpfulStuff.GetUTF8Bytes(myString)

Il grosso problema è che quando l'autore di HelpfulStuff esce e io sono seduto davanti al monitor, non ho modo di sapere dove viene implementato GetUTF8Bytes .

Lo cerco in HelpfulStuff , Utils , StringHelper ?

Signore aiutami se il metodo è effettivamente implementato in modo indipendente in tutti e tre questi ... il che accade molto, basta che il ragazzo prima di me non sapesse dove cercare quella funzione, e credeva che non ci fosse Non ce ne sono ancora così ne hanno strappato un altro e ora li abbiamo in abbondanza.

Per me, il design corretto non riguarda la soddisfazione di criteri astratti, ma della mia facilità di andare avanti dopo essermi seduto di fronte al tuo codice. Ora come fa

EncodersFactory.getDefaultFactory().createEncoder(new UTF8Encoding()).getBytes(myString)

Vota in questo aspetto? Povero pure. Non è una risposta che voglio sentire quando urlo al mio compagno di lavoro "come faccio a codificare una stringa in una sequenza di byte?" :)

Quindi andrei con un approccio moderato e sarei liberale riguardo alle singole responsabilità. Preferirei che la classe Encoding identificasse un tipo di codifica e eseguisse la codifica effettiva: dati non separati dal comportamento.

Penso che passi come un modello di facciata, che aiuta ad ingrassare le marce. Questo modello legittimo viola SOLID? Una domanda correlata:

Si noti che questo potrebbe essere il modo in cui i byte codificati vengono implementati internamente (nelle librerie .NET). Le classi non sono pubblicamente visibili e solo questa API "facciata" viene esposta all'esterno. Non è così difficile verificare se questo è vero, sono solo un po 'troppo pigro per farlo adesso :) Probabilmente non lo è, ma ciò non inficia il mio punto.

Potresti scegliere di semplificare l'implementazione visibile pubblicamente per motivi di amicizia, mantenendo internamente un'implementazione canonica più severa.

    
risposta data 05.12.2013 - 11:59
fonte
1

In Java l'astrazione utilizzata per rappresentare i file è un flusso, alcuni flussi sono unidirezionali (sola lettura o solo scrittura) altri sono bidirezionali. Per Ruby la classe File è l'astrazione che rappresenta i file del sistema operativo. Quindi la domanda diventa qual è la sua unica responsabilità? In Java, la responsabilità di FileWriter è quella di fornire un flusso unidirezionale di byte che è connesso a un file OS. In Ruby, la responsabilità di File è di fornire un accesso bidirezionale a un file di sistema. Entrambi soddisfano l'SRP hanno solo responsabilità diverse.

It's not a Car's or a Dog's responsibility to convert itself to a string, now is it?

Certo, perché no? Mi aspetto che la classe Car rappresenti pienamente un'astrazione Car. Se è logico che l'astrazione dell'auto venga utilizzata laddove è necessaria una stringa, mi aspetto che la classe Car supporti la conversione in una stringa.

Per rispondere alla domanda più ampia direttamente la responsabilità di una classe è quella di agire come un'astrazione. Quel lavoro può essere complesso, ma questo è un po 'il punto, un'astrazione dovrebbe nascondere cose complesse dietro un'interfaccia più facile da usare e meno complessa. L'SRP è una linea guida di progettazione in modo da concentrarti sulla domanda "che cosa rappresenta questa astrazione?". Se sono necessari più comportamenti diversi per astrarre completamente il concetto, allora così sia.

    
risposta data 04.12.2013 - 17:36
fonte
0

La classe può avere molteplici responsabilità. Almeno nel classico OOP incentrato sui dati.

Se applichi il principio di responsabilità individuale nella sua massima estensione, ricevi la responsabilità -centric design dove fondamentalmente ogni metodo non-helper appartiene alla sua classe.

Penso che non ci sia nulla di sbagliato nell'estrarre un pezzo di complessità da una classe base come nel caso di File e FileWriter. Il vantaggio è che si ottiene una chiara separazione del codice responsabile di una determinata operazione e anche che è possibile sovrascrivere (sottoclasse) solo quella parte di codice anziché sovrascrivere l'intera classe base (ad esempio File). Tuttavia, applicarlo sembra sistematicamente eccessivo. È sufficiente ottenere molte più classi da gestire e per ogni operazione è necessario richiamare la rispettiva classe. È la flessibilità che ha un costo.

Queste classi estratte dovrebbero avere nomi molto descrittivi basati sull'operazione che contengono, ad es. <X>Writer , <X>Reader , <X>Builder . Nomi come <X>Manager , <X>Handler non descrivono affatto l'operazione e se vengono chiamati così perché contengono più di un'operazione, allora non è chiaro cosa hai ottenuto. Hai separato la funzionalità dai suoi dati, hai comunque infranto il principio di responsabilità singola anche in quella classe estratta, e se stai cercando un determinato metodo potresti non sapere dove cercarlo (se in <X> o <X>Manager ) .

Ora, il tuo caso con UserDataHandler è diverso perché non esiste una classe base (la classe che contiene dati reali). I dati per questa classe sono memorizzati in una risorsa esterna (database) e si sta utilizzando una API per accedervi e manipolarli. La tua classe utente rappresenta un utente run-time, che è in realtà qualcosa di diverso da un utente persistente e la separazione delle responsabilità (preoccupazioni) potrebbe tornare utile qui, soprattutto se hai un sacco di logica relativa all'utente in fase di esecuzione.

Probabilmente hai chiamato UserDataHandler in quel modo perché fondamentalmente ci sono solo funzionalità in quella classe e nessun dato reale. Tuttavia, potresti anche astrarre da questo fatto e considerare i dati nel database come appartenenti alla classe. Con questa astrazione puoi nominare la classe basandoti su che cos'è e non su ciò che fa. Puoi dargli un nome come UserRepository, che è piuttosto lucido e suggerisce anche l'uso del schema del repository .

    
risposta data 08.12.2013 - 19:30
fonte

Leggi altre domande sui tag