Dovremmo definire i tipi per tutto?

138

Recentemente ho avuto un problema con la leggibilità del mio codice.
Avevo una funzione che eseguiva un'operazione e restituiva una stringa che rappresenta l'ID di questa operazione per riferimento futuro (un po 'come OpenFile in Windows che restituisce un handle). L'utente userebbe questo ID in seguito per iniziare l'operazione e per monitorarne la fine.

L'ID doveva essere una stringa casuale a causa di problemi di interoperabilità. Questo ha creato un metodo che aveva una firma molto poco chiara in questo modo:

public string CreateNewThing()

Ciò rende poco chiaro l'intento del tipo di ritorno. Ho pensato di avvolgere questa stringa in un altro tipo che rende il suo significato più chiaro come in questo modo:

public OperationIdentifier CreateNewThing()

Il tipo conterrà solo la stringa e verrà utilizzato ogni volta che viene utilizzata questa stringa.

È ovvio che il vantaggio di questo modo di operare è più sicurezza di tipo e intento più chiaro, ma crea anche molto più codice e codice che non è molto idiomatico. Da un lato mi piace la sicurezza aggiunta, ma crea anche un sacco di confusione.

Pensi che sia una buona pratica racchiudere tipi semplici in una classe per motivi di sicurezza?

    
posta Ziv 03.05.2015 - 18:30
fonte

10 risposte

150

I primitivi, come string o int , non hanno alcun significato in un dominio aziendale. Una conseguenza diretta di questo è che potresti erroneamente utilizzare un URL quando è previsto un ID prodotto o utilizzare la quantità quando ti aspetti il prezzo .

Questo è anche il motivo per Calisthenics Object sfida caratteristiche primitive wrapping come una delle sue regole:

Rule 3: Wrap all primitives and Strings

In the Java language, int is a primitive, not a real object, so it obeys different rules than objects. It is used with a syntax that isn’t object-oriented. More importantly, an int on its own is just a scalar, so it has no meaning. When a method takes an int as a parameter, the method name needs to do all of the work of expressing the intent. If the same method takes an Hour as a parameter, it’s much easier to see what’s going on.

Lo stesso documento spiega che c'è un ulteriore vantaggio:

Small objects like Hour or Money also give us an obvious place to put behavior that would otherwise have been littered around other classes.

In effetti, quando vengono utilizzati i primitivi, di solito è estremamente difficile rintracciare l'esatta posizione del codice relativo a tali tipi, che spesso porta a una severa duplicazione del codice . Se è presente la classe Price: Money , è naturale trovare il controllo dell'intervallo all'interno. Se, invece, viene utilizzato un int (peggio, un double ) per archiviare i prezzi dei prodotti, chi dovrebbe convalidare l'intervallo? Il prodotto? Lo sconto? Il carrello?

Infine, un terzo beneficio non menzionato nel documento è la possibilità di cambiare relativamente facilmente il tipo sottostante. Se oggi il mio ProductId ha short come tipo sottostante e successivamente ho bisogno di usare int , è probabile che il codice da cambiare non si estenda all'intero code base.

L'inconveniente - e lo stesso argomento vale per ogni regola di esercizio di Calisthenics degli oggetti - è che se diventa rapidamente troppo travolgente per creare una classe per ogni cosa . Se Product contiene ProductPrice che eredita da PositivePrice che eredita da Price che a sua volta eredita da Money , questo non è l'architettura pulita, ma piuttosto un pasticcio completo dove, al fine di trovare una sola cosa, un il maintainer dovrebbe aprire qualche dozzina di file ogni volta.

Un altro punto da considerare è il costo (in termini di righe di codice) della creazione di classi aggiuntive. Se i wrapper sono immutabili (come dovrebbero essere, di solito), significa che, se prendiamo C #, devi avere, all'interno del wrapper almeno:

  • La proprietà getter,
  • Il suo campo di supporto,
  • Un costruttore che assegna il valore al campo di supporto,
  • Un% personalizzatoToString(),
  • Commenti della documentazione XML (che rende molto di linee),
  • Un Equals e un GetHashCode sostituisce (anche un sacco di LOC).

e alla fine, se pertinente:

  • DebuggerDisplay attributo,
  • Un override di == e != operatori,
  • Alla fine un sovraccarico dell'operatore di conversione implicita per convertire senza problemi da e verso il tipo incapsulato,
  • Contratti di codice (incluso l'invariante, che è un metodo piuttosto lungo, con i suoi tre attributi),
  • Diversi convertitori che verranno utilizzati durante la serializzazione XML, la serializzazione JSON o l'archiviazione / caricamento di un valore in / da un database.

Un centinaio di LOC per un semplice wrapper lo rende abbastanza proibitivo, motivo per cui si può essere completamente sicuri della redditività a lungo termine di tale wrapper. La nozione di ambito spiegata da Thomas Junk è particolarmente rilevante qui. Scrivere un centinaio di LOC per rappresentare un ProductId utilizzato su tutto il tuo codice base sembra abbastanza utile. Scrivere una classe di queste dimensioni per un pezzo di codice che rende tre righe all'interno di un singolo metodo è molto più discutibile.

Conclusione:

  • Fare primitive avvolgere in classi che hanno un significato in un dominio di business dell'applicazione quando (1) aiuta a ridurre gli errori, (2) riduce il rischio di duplicazioni codice o (3) aiuta la modifica del tipo di fondo in seguito .

  • Non avvolgere automaticamente ogni primitiva che trovi nel tuo codice: ci sono molti casi in cui l'uso di string o int è perfettamente corretto.

In pratica, in public string CreateNewThing() , restituire un'istanza di ThingId class invece di string potrebbe essere d'aiuto, ma potresti anche:

  • Restituisce un'istanza di Id<string> class, ovvero un oggetto di tipo generico che indica che il tipo sottostante è una stringa. Hai il vantaggio della leggibilità, senza lo svantaggio di dover mantenere molti tipi.

  • Restituisce un'istanza di Thing class. Se l'utente ha solo bisogno dell'ID, questo può essere fatto facilmente con:

    var thing = this.CreateNewThing();
    var id = thing.Id;
    
risposta data 03.05.2015 - 19:09
fonte
59

Vorrei usare lo scope come regola pratica: quanto più ristretto è lo scopo di generare e consumare tale values , tanto meno è probabile che tu debba creare un oggetto che rappresenta questo valore.

Supponiamo di avere il seguente pseudocode

id = generateProcessId();
doFancyOtherstuff();
job.do(id);

quindi lo scopo è molto limitato e non avrei senso nel renderlo un tipo. Ad esempio, si genera questo valore in un livello e lo si passa ad un altro livello (o anche ad un altro oggetto), quindi sarebbe perfettamente logico creare un tipo per questo.

    
risposta data 03.05.2015 - 19:15
fonte
34

I sistemi di tipo statico riguardano solo la prevenzione di usi non corretti dei dati.

Ci sono ovvi esempi di tipi che fanno questo:

  • non puoi ottenere il mese di un UUID
  • non puoi moltiplicare due stringhe.

Ci sono esempi più sottili

  • non puoi pagare nulla usando la lunghezza della scrivania
  • non puoi effettuare una richiesta HTTP utilizzando il nome di qualcuno come URL.

Potremmo essere tentati di utilizzare double per prezzo e lunghezza oppure utilizzare string per un nome e un URL. Ma questo sovverte il nostro fantastico sistema di tipi e consente a questi abusi di superare i controlli statici del linguaggio.

Ottenere sterline-secondi confusi con Newton-secondi può avere risultati scadenti in fase di runtime .

Questo è particolarmente un problema con le stringhe. Spesso diventano il "tipo di dati universale".

Siamo abituati principalmente alle interfacce testuali con i computer e spesso estendiamo queste interfacce umane (UI) a interfacce di programmazione (API). Pensiamo a 34.25 come caratteri 34.25 . Pensiamo a una data come i caratteri 05-03-2015 . Pensiamo a un UUID come ai caratteri 75e945ee-f1e9-11e4-b9b2-1697f925ec7b .

Ma questo modello mentale danneggia l'astrazione dell'API.

The words or the language, as they are written or spoken, do not seem to play any role in my mechanism of thought.

Albert Einstein

Allo stesso modo, le rappresentazioni testuali non dovrebbero avere alcun ruolo nella progettazione di tipi e API. Attenti al string ! (e altri tipi "primitivi" eccessivamente generici)

Tipi comunicano "quali operazioni hanno senso."

Ad esempio, una volta ho lavorato su un client a un'API REST HTTP. REST, fatto correttamente, usa le entità hypermedia, che hanno collegamenti ipertestuali che puntano a entità correlate. In questo client, non solo le entità sono state digitate (ad esempio Utente, Account, Abbonamento), anche i collegamenti a tali entità sono stati digitati (UserLink, AccountLink, SubscriptionLink). I collegamenti erano poco più di wrapper attorno a Uri , ma i tipi separati rendevano impossibile provare a utilizzare un ContoLink per recuperare un Utente. Se tutto fosse stato normale Uri s - o anche peggio, string - questi errori si troverebbero solo in fase di esecuzione.

Allo stesso modo, nella tua situazione, hai dati che vengono utilizzati per un solo scopo: identificare un Operation . Non dovrebbe essere usato per nient'altro, e non dovremmo cercare di identificare Operation s con le stringhe casuali che abbiamo inventato. Creare una classe separata aggiunge leggibilità e sicurezza al tuo codice.

Ovviamente, tutte le cose buone possono essere usate in eccesso. Considerare

  1. quanta chiarezza aggiunge al tuo codice

  2. quanto spesso viene utilizzato

Se un "tipo" di dati (in senso astratto) è usato frequentemente, per scopi distinti, e tra interfacce di codice, è un ottimo candidato per essere una classe separata, a scapito della verbosità.

    
risposta data 04.05.2015 - 01:31
fonte
10

In genere sono d'accordo sul fatto che molte volte dovresti creare un tipo per primitive e stringhe, ma poiché le risposte sopra suggeriscono di creare un tipo nella maggior parte dei casi, elencherò alcuni motivi per cui / quando non farlo:

  • Prestazioni. Devo fare riferimento alle lingue attuali qui. In C #, se un ID cliente è un corto e lo si avvolge in una classe - si è appena creato un sacco di overhead: memoria, dato che ora sono 8 byte su un sistema a 64 bit e la velocità è ora allocata nell'heap.
  • Quando fai supposizioni sul tipo. Se un ID cliente è breve e hai una logica che lo racchiude in qualche modo, di solito fai già delle ipotesi sul tipo. In tutti quei posti ora devi rompere l'astrazione. Se è un punto, non è un grosso problema, se è dappertutto, potresti scoprire che metà del tempo stai usando la primitiva.
  • Perché non tutte le lingue hanno typedef. E per le lingue che non lo fanno e il codice che è già stato scritto, apportare un tale cambiamento potrebbe essere un grosso problema che potrebbe anche introdurre bug (ma tutti hanno una suite di test con una buona copertura, giusto?).
  • In alcuni casi riduce la leggibilità. Come posso stampare questo? Come convalidarlo? Devo controllare null? Sono tutte domande che richiedono il drill nella definizione del tipo.
risposta data 04.05.2015 - 16:41
fonte
9

Do you think it is a good practice to wrap simple types in a class for safety reasons?

A volte.

Questo è uno di quei casi in cui è necessario valutare i problemi che potrebbero sorgere dall'utilizzo di string anziché di un% più concreto diOperationIdentifier. Quali sono la loro gravità? Qual è la loro probabilità?

Quindi devi considerare il costo dell'utilizzo dell'altro tipo. Quanto è doloroso da usare? Quanto lavoro è da fare?

In alcuni casi, risparmierai tempo e fatica avendo un buon tipo concreto contro cui lavorare. In altri, non ne varrebbe la pena.

In generale, penso che questo genere di cose dovrebbe essere fatto più di quanto non sia oggi. Se hai un'entità che significa qualcosa nel tuo dominio, è bene averla come un suo tipo, dal momento che quell'entità ha maggiori probabilità di cambiare / crescere con il business.

    
risposta data 03.05.2015 - 18:41
fonte
7

No, non dovresti definire tipi (classi) per "tutto".

Tuttavia, come indicano altre risposte, è spesso utile a farlo. Dovresti sviluppare - consapevolmente, se possibile - un sentimento o senso di troppo-attrito dovuto alla assenza di un tipo o classe adatto, mentre scrivi, collaudi e mantieni il tuo codice. Per me, l'inizio del troppo attrito è quando voglio consolidare più valori primitivi come un singolo valore o quando devo convalidare i valori (cioè determinare quale di tutti i possibili valori del tipo primitivo corrispondono a valori validi del 'tipo implicito').

Ho riscontrato che considerazioni come quella che hai posto nella tua domanda sono responsabili del design troppo nel mio codice. Ho sviluppato l'abitudine di evitare deliberatamente di scrivere più codice del necessario. Spero che tu stia scrivendo buoni test (automatizzati) per il tuo codice: se lo sei, puoi facilmente rifattorizzare il tuo codice e aggiungere tipi o classi se ciò comporta vantaggi netti per lo sviluppo e la manutenzione continui del tuo codice .

Risposta di Telastyn e La risposta di Thomas Junk rappresenta un ottimo punto sul contesto e sull'utilizzo del codice pertinente. Se stai utilizzando un valore all'interno di un singolo blocco di codice (ad es. Metodo, loop, using block), allora va bene usare solo un tipo primitivo. Va bene anche usare un tipo primitivo se stai usando un insieme di valori ripetutamente e in molti altri posti. Ma più frequentemente e ampiamente usi un insieme di valori, e meno strettamente quell'insieme di valori corrisponde ai valori rappresentati dal tipo primitivo, più dovresti considerare di incapsulare i valori in una classe o in un tipo.

    
risposta data 03.05.2015 - 23:09
fonte
5

Sulla superficie sembra che tutto ciò che devi fare è identificare un'operazione.

Inoltre dici cosa si deve fare un'operazione:

to start the operation [and] to monitor its finish

Lo dici in un modo come se questo fosse "esattamente come si usa l'identificazione", ma direi che si tratta di proprietà che descrivono un'operazione. Mi sembra una definizione di tipo, esiste persino un pattern chiamato "schema di comando" che si adatta molto bene. .

Dice

the command pattern [...] is used to encapsulate all information needed to perform an action or trigger an event at a later time.

Penso che sia molto simile a quello che vuoi fare con le tue operazioni. (Confronta le frasi che ho messo in grassetto in entrambe le virgolette) Invece di restituire una stringa, restituisci un identificatore per un Operation in senso astratto, ad esempio un puntatore a un oggetto di quella classe in oop.

Riguardo al commento

It would be wtf-y to call this class a command and then not have the logic inside it.

No, non lo farebbe. Ricorda che i pattern sono molto astratti , così astratti che sono in qualche modo meta. Cioè, spesso astraggono la programmazione stessa e non un concetto del mondo reale. Lo schema di comando è un'astrazione di una chiamata di funzione. (o metodo) Come se qualcuno avesse premuto il pulsante di pausa subito dopo aver passato i valori dei parametri e subito prima dell'esecuzione, per riprendere in seguito.

Quanto segue sta considerando Oop, ma la motivazione che sta dietro dovrebbe essere vera per qualsiasi paradigma. Mi piace dare alcuni motivi per cui posizionare la logica nel comando può essere considerata una cosa negativa.

  1. Se tu avessi tutta questa logica nel comando, sarebbe gonfio e disordinato. tu penserei "vabbè, tutta questa funzionalità dovrebbe essere dentro classi separate. "Dovresti refactare la logica del comando.
  2. Se avessi tutta questa logica nel comando, sarebbe difficile da testare e intromettersi nei test. "Santo cielo, perché devo usare questo comando senza senso? Voglio solo verificare se questa funzione sputa fuori 1 o no, non voglio chiamarlo più tardi, voglio testarlo ora! "
  3. Se tu avessi tutta questa logica nel comando, non farebbe troppo senso di riferire al termine. Se pensi a una funzione per essere eseguito in modo sincrono per impostazione predefinita, non ha senso essere notificato quando termina l'esecuzione. Forse ne stai iniziando un altro thread perché la tua operazione è in realtà per rendere avatar al cinema formato (potrebbe essere necessario un thread aggiuntivo su raspberry pi), forse lo hai per recuperare alcuni dati da un server, ... qualunque cosa sia, se c'è un motivo per la segnalazione al termine, potrebbe essere perché non c'è un po 'di asincrona (è una parola?) in corso. Penso che correre a thread o contattare un server è una logica che non dovrebbe essere nel tuo comando. Questo è un po 'una meta argomentazione e forse controverso. Se ritieni che non abbia senso, ti preghiamo di comunicarmelo nei commenti.

Per ricapitolare: il modello di comando consente di avvolgere funzionalità  in un oggetto, da eseguire in seguito. Per motivi di modularità (la funzionalità esiste indipendentemente dall'esecuzione tramite comando o meno), testabilità (la funzionalità dovrebbe essere testabile senza comando) e tutte quelle altre parole buzz che essenzialmente esprimono la richiesta di scrivere un buon codice, non si metterebbe la logica effettiva nel comando.

Poiché i pattern sono astratti, può essere difficile trovare buone metafore del mondo reale. Ecco un tentativo:

"Ehi, nonna, potresti premere il pulsante di registrazione sulla TV alle 12 in modo da non perdere i simpson sul canale 1?"

Mia nonna non sa cosa succede tecnicamente quando colpisce il pulsante di registrazione. La logica è altrove (nella TV). E questa è una buona cosa. La funzionalità è incapsulata e le informazioni sono nascoste dal comando, è un utente dell'API, non necessariamente parte della logica ... oh jeez Sto di nuovo balbettando parole vocali, è meglio che finisca questa modifica ora.

    
risposta data 04.05.2015 - 18:37
fonte
5

Idea dietro al wrapping dei tipi primitivi,

  • Stabilire una lingua specifica per il dominio
  • Limita gli errori fatti dagli utenti passando valori errati

Ovviamente sarebbe molto difficile e non pratico farlo ovunque, ma è importante avvolgere i tipi dove richiesto,

Ad esempio se hai l'ordine di classe,

Order
{
   long OrderId { get; set; }
   string InvoiceNumber { get; set; }
   string Description { get; set; }
   double Amount { get; set; }
   string CurrencyCode { get; set; }
}

Le proprietà importanti per cercare un Ordine sono principalmente OrderId e InvoiceNumber. E l'importo e il CurrencyCode sono strettamente correlati, dove se qualcuno cambia il CurrencyCode senza cambiare l'importo, l'ordine non può più essere considerato valido.

Quindi, solo il wrapping OrderId, InvoiceNumber e l'introduzione di un composito per la valuta ha senso in questo scenario e probabilmente la descrizione del wrapping non ha senso. Quindi il risultato preferito potrebbe essere simile a

    Order
    {
       OrderIdType OrderId { get; set; }
       InvoiceNumberType InvoiceNumber { get; set; }
       string Description { get; set; }
       Currency Amount { get; set; }
    }

Quindi non c'è motivo di avvolgere tutto, ma le cose che contano davvero.

    
risposta data 04.05.2015 - 10:02
fonte
4

Opinione impopolare:

Di solito non dovresti definire un nuovo tipo!

Definire un nuovo tipo per racchiudere una classe primitiva o di base viene talvolta definito definendo uno pseudo-tipo. IBM descrive questo come una cattiva pratica qui (in questo caso si focalizzano specificamente sull'abuso con i generici).

Gli pseudo tipi rendono inutilizzabile la funzionalità della libreria comune.

Le funzioni matematiche di Java possono funzionare con tutti i numeri primitivi. Ma se si definisce una nuova classe Percentuale (che racchiude un doppio che può essere compreso nell'intervallo 0 ~ 1) tutte queste funzioni sono inutili e devono essere racchiuse da classi che (ancora peggio) devono conoscere la rappresentazione interna della classe Percentuale .

Lo pseudo tipo diventa virale

Quando crei più librerie troverai spesso che questi pseudotipi diventano virali. Se usi la classe Percentage sopra menzionata in una libreria dovrai convertirla al limite della biblioteca (perdendo ogni sicurezza / significato / logica / altra ragione che hai avuto per creare quel tipo) o dovrai fare queste classi accessibile anche all'altra libreria. Infettare la nuova libreria con i tuoi tipi dove sarebbe bastato un semplice doppio.

messaggio Take away

Finché il tipo di wrap non ha bisogno di molte logiche di business, consiglierei di includerlo in una pseudo classe. Dovresti solo concludere un corso se ci sono seri limiti di business. In altri casi, nominare le tue variabili correttamente dovrebbe fare molto per trasmettere significato.

Un esempio:

Un uint può rappresentare perfettamente un UserId, possiamo continuare ad usare gli operatori integrati di Java per uint s (come ==) e non abbiamo bisogno di logica aziendale per proteggere lo 'stato interno' dell'id utente .

    
risposta data 06.05.2015 - 10:29
fonte
3

Come tutti i suggerimenti, sapere quando applicare le regole è l'abilità. Se crei i tuoi tipi in una lingua basata sui caratteri, ottieni il controllo dei tipi. Quindi generalmente questo sarà un buon piano.

NamingConvention è la chiave per la leggibilità. I due combinati possono trasmettere chiaramente l'intenzione.
Ma /// è ancora utile.

Quindi sì, direi creare molti tipi propri quando la loro vita è al di là del confine di classe. Considera anche l'uso di Struct e Class , non sempre di Classe.

    
risposta data 03.05.2015 - 18:44
fonte

Leggi altre domande sui tag