Perché i linguaggi di programmazione consentono l'ombreggiamento / occultamento di variabili e funzioni?


31

Molti dei linguaggi di programmazione più popolari (come C ++, Java, Python ecc.) Hanno il concetto di nascondere / oscurare variabili o funzioni. Quando ho incontrato nascondigli o ombre sono stati la causa di bug difficili da trovare e non ho mai visto un caso in cui ho trovato necessario utilizzare queste funzionalità delle lingue.

A me sembrerebbe meglio non nascondere e nascondere.

Qualcuno sa di un buon uso di questi concetti?

Aggiornamento:
non mi riferisco all'incapsulamento dei membri della classe (membri privati ​​/ protetti).


Ecco perché tutti i miei nomi di campo iniziano con F.
Pieter B

7
Penso che Eric Lippert abbia avuto un bell'articolo su questo. Oh aspetta, eccolo: blogs.msdn.com/b/ericlippert/archive/2008/05/21/…
Lescai Ionel

1
Per favore chiarisci la tua domanda. Stai chiedendo informazioni su come nascondere le informazioni in generale o sul caso specifico descritto nell'articolo Lippert in cui una classe derivata nasconde funzioni della classe base?
Aaron Kurtzhals,

Nota importante: molti dei bug causati da nascondere / oscurare riguardano la mutazione (impostando la variabile sbagliata e chiedendosi perché il cambiamento "non accade mai" per esempio). Quando si lavora principalmente con riferimenti immutabili, nascondere / oscurare causa molti meno problemi ed è molto meno probabile che causi bug.
Jack,

Risposte:


26

Se non si consente di nascondere e oscurare, ciò che si possiede è una lingua in cui tutte le variabili sono globali.

È chiaramente peggio che consentire variabili o funzioni locali che potrebbero nascondere variabili o funzioni globali.

Se non si consente il nascondimento e l'ombreggiatura e si tenta di "proteggere" determinate variabili globali, si crea una situazione in cui il compilatore dice al programmatore "Mi dispiace, Dave, ma non puoi usare quel nome, è già in uso ". L'esperienza con COBOL dimostra che i programmatori ricorrono quasi immediatamente a volgarità in questa situazione.

Il problema fondamentale non è nascondere / oscurare, ma variabili globali.


19
Un altro svantaggio del divieto dell'ombreggiatura è che l'aggiunta di una variabile globale potrebbe violare il codice perché la variabile era già stata utilizzata in un blocco locale.
Giorgio,

19
"Se non si consente di nascondere e nascondere, ciò che si possiede è una lingua in cui tutte le variabili sono globali." - non necessariamente: puoi avere variabili con ambito senza ombreggiatura e te lo hai spiegato.
Thiago Silva,

@ThiagoSilva: E quindi la tua lingua deve avere un modo per dire al compilatore che questo modulo è autorizzato ad accedere alla variabile "frammis" di quel modulo. Consenti a qualcuno di nascondere / ombreggiare un oggetto che non sa nemmeno che esiste, o glielo dici per dirgli perché non gli è permesso usare quel nome?
John R. Strohm,

9
@Phil, mi scusi per non essere d'accordo con te, ma l'OP ha chiesto "nascondere / oscurare variabili o funzioni", e le parole "genitore", "figlio", "classe" e "membro" non appaiono da nessuna parte nella sua domanda. Ciò sembrerebbe renderlo una domanda generale sull'ambito dei nomi.
John R. Strohm,

3
@dylnmc, non mi aspettavo di vivere abbastanza a lungo per incontrare un bastardo più giovane abbastanza da non ottenere un ovvio riferimento "2001: Odissea nello spazio".
John R. Strohm,

15

Qualcuno sa di un buon uso di questi concetti?

L'uso di identificatori accurati e descrittivi è sempre un buon uso.

Potrei sostenere che il nascondimento delle variabili non causa molti bug poiché avere due variabili con nomi simili dello stesso / tipi simili (cosa farebbe se il nascondimento delle variabili fosse vietato) è probabile che causi altrettanti bug e / o altrettanto bug gravi. Non so se tale argomento sia corretto , ma è almeno plausibilmente discutibile.

L'uso di una sorta di notazione ungherese per differenziare i campi rispetto alle variabili locali aggira questo, ma ha il suo impatto sulla manutenzione (e sulla sanità mentale del programmatore).

E (forse molto probabilmente la ragione per cui il concetto è noto in primo luogo) è molto più facile per le lingue implementare nascondigli / ombreggiature che non vietarlo. Una più semplice implementazione significa che i compilatori hanno meno probabilità di avere bug. Una più semplice implementazione significa che i compilatori impiegano meno tempo a scrivere, causando un'adozione più ampia e più ampia della piattaforma.


3
In realtà, no, NON è più facile implementare nascondigli e ombre. In realtà è più semplice implementare "tutte le variabili sono globali". Hai solo bisogno di uno spazio dei nomi e esporti SEMPRE il nome, invece di avere più spazi dei nomi e dover decidere per ogni nome se esportarlo.
John R. Strohm,

5
@ JohnR.Strohm - Certo, ma non appena hai qualche tipo di scoping (leggi: classi), allora avere gli ambiti che nascondono gli ambiti inferiori è lì gratuitamente.
Telastyn,

Scoping e classi sono cose diverse. Con l'eccezione di BASIC, ogni lingua in cui ho programmato ha l'ambito, ma non tutte hanno un concetto di classi o oggetti.
Michael Shaw,

@michaelshaw - ovviamente, avrei dovuto essere più chiaro.
Telastyn,

7

Solo per essere sicuri che siamo sulla stessa pagina, il metodo "nascondere" è quando una classe derivata definisce un membro con lo stesso nome di uno nella classe base (che, se si tratta di un metodo / proprietà, non è contrassegnato come virtuale / sostituibile ) e quando viene invocato da un'istanza della classe derivata in "contesto derivato", viene utilizzato il membro derivato, mentre se invocato dalla stessa istanza nel contesto della sua classe base, viene utilizzato il membro della classe base. Ciò è diverso dall'astrazione / sostituzione del membro in cui il membro della classe base si aspetta che la classe derivata definisca una sostituzione e dai modificatori di ambito / visibilità che "nascondono" un membro dai consumatori al di fuori dell'ambito desiderato.

La risposta breve al perché è consentito è che non farlo costringerebbe gli sviluppatori a violare diversi principi chiave della progettazione orientata agli oggetti.

Ecco la risposta più lunga; innanzitutto, considera la seguente struttura di classe in un universo alternativo in cui C # non consente il nascondimento dei membri:

public interface IFoo
{
   string MyFooString {get;}
   int FooMethod();
}

public class Foo:IFoo
{
   public string MyFooString {get{return "Foo";}}
   public int FooMethod() {//incredibly useful code here};
}

public class Bar:Foo
{
   //public new string MyFooString {get{return "Bar";}}
}

Vogliamo decommentare il membro in Bar e, in tal modo, consentire a Bar di fornire un MyFooString diverso. Tuttavia, non possiamo farlo perché violerebbe il divieto della realtà alternativa di nascondere i membri. Questo esempio particolare sarebbe diffuso per i bug ed è un ottimo esempio del motivo per cui potresti voler vietarlo; ad esempio, quale output di console otterresti se avessi fatto quanto segue?

Bar myBar = new Bar();
Foo myFoo = myBar;
IFoo myIFoo = myFoo;

Console.WriteLine(myFoo.MyFooString);
Console.WriteLine(myBar.MyFooString);
Console.WriteLine(myIFoo.MyFooString);

In cima alla mia testa, in realtà non sono sicuro se avresti trovato "Foo" o "Bar" su quest'ultima riga. Otterresti sicuramente "Foo" per la prima riga e "Bar" per la seconda, anche se tutte e tre le variabili fanno riferimento esattamente alla stessa istanza con esattamente lo stesso stato.

Quindi, i progettisti del linguaggio, nel nostro universo alternativo, scoraggiano questo codice ovviamente negativo impedendo il nascondimento delle proprietà. Ora, come programmatore, hai davvero bisogno di fare esattamente questo. Come aggirare la limitazione? Bene, un modo è quello di nominare la proprietà di Bar in modo diverso:

public class Bar:Foo
{
   public string MyBarString {get{return "Bar";}}       
}

Perfettamente legale, ma non è il comportamento che vogliamo. Un'istanza di Bar produrrà sempre "Foo" per la proprietà MyFooString, quando volevamo che producesse "Bar". Non solo dobbiamo sapere che il nostro IFoo è specificamente un bar, ma dobbiamo anche sapere di utilizzare i diversi accessori.

Potremmo anche, abbastanza plausibilmente, dimenticare la relazione genitore-figlio e implementare direttamente l'interfaccia:

public class Bar:IFoo
{
   public string MyFooString {get{return "Bar";}}
   public int FooMethod() {...}
}

Per questo semplice esempio è una risposta perfetta, purché ti interessi solo che Foo e Bar siano entrambi IFoos. Il codice di utilizzo che un paio di esempi non riuscirebbe a compilare perché una barra non è un Foo e non può essere assegnata come tale. Tuttavia, se Foo aveva qualche metodo utile "FooMethod" di cui aveva bisogno Bar, ora non puoi ereditare quel metodo; dovresti clonare il suo codice nella barra o essere creativo:

public class Bar:IFoo
{
   public string MyFooString {get{return "Bar";}}
   private readonly theFoo = new Foo();

   public int FooMethod(){return theFoo.FooMethod();}
}

Questo è un trucco evidente, e mentre alcune implementazioni delle specifiche del linguaggio OO ammontano a poco più di questo, concettualmente è sbagliato; se i consumatori di Bar necessità di esporre la funzionalità di Foo, Bar dovrebbe essere un Foo, non hanno un Foo.

Ovviamente, se abbiamo controllato Foo, possiamo renderlo virtuale, quindi sovrascriverlo. Questa è la migliore pratica concettuale nel nostro universo attuale quando ci si aspetta che un membro venga sovrascritto, e si reggerebbe in qualsiasi universo alternativo che non permettesse di nascondersi:

public class Foo:IFoo
{
   public virtual string MyFooString {get{return "Foo";}}
   //...
}

public class Bar:Foo
{
   public override string MyFooString {get{return "Bar";}}
}

Il problema è che l'accesso virtuale ai membri è, sotto il cofano, relativamente più costoso da eseguire, e quindi in genere si desidera farlo solo quando è necessario. La mancanza di nascondimento, tuttavia, ti costringe a essere pessimista sui membri che un altro programmatore che non controlla il tuo codice sorgente potrebbe voler reimplementare; la "migliore pratica" per qualsiasi classe non sigillata sarebbe quella di rendere tutto virtuale a meno che tu non lo volessi specificamente. Inoltre non ti dà ancora il comportamento esatto di nasconderti; la stringa sarà sempre "Bar" se l'istanza è una barra. A volte è davvero utile sfruttare i livelli di dati di stato nascosti, in base al livello di eredità a cui stai lavorando.

In sintesi, consentire il nascondimento dei membri è il minore di questi mali. Non averlo in genere porterebbe a peggiori atrocità commesse contro principi orientati agli oggetti di quanto non lo consenta.


+1 per rispondere alla domanda effettiva. Un buon esempio di utilizzo nel mondo reale del nascondere i membri è l' interfaccia IEnumerablee IEnumerable<T>, descritto nel post sul blog di Eric Libbert sull'argomento.
Phil

L'override non si nasconde. Non sono d'accordo con @Phil che questo affronta la domanda.
Jan Hudec,

Il mio punto era che l'override sarebbe un sostituto per nascondersi quando nascondersi non è un'opzione. Sono d'accordo, non si nasconde, e lo dico tanto nel primo paragrafo. Nessuna delle soluzioni alternative al mio scenario di realtà alternativa di non nascondermi in C # si nasconde; questo è il punto.
KeithS,

Non mi piacciono i tuoi usi di ombreggiatura / nascondiglio. I principali usi che vedo sono (1) aggirarsi attorno alla situazione in cui una nuova versione di una classe base include un membro che è in conflitto con il codice del consumatore progettato attorno a una versione precedente [brutta ma necessaria]; (2) fingendo cose come la covarianza di tipo ritorno; (3) trattare casi in cui un metodo di classe base è richiamabile su un particolare sottotipo ma non utile . L'LSP richiede il primo, ma non il secondo se il contratto di classe base specifica che alcuni metodi potrebbero non essere utili in alcune condizioni.
supercat

2

Onestamente, Eric Lippert, il principale sviluppatore del team di compilatori C #, lo spiega abbastanza bene (grazie Lescai Ionel per il link). .NET IEnumerablee le IEnumerable<T>interfacce sono buoni esempi di quando è utile nascondere i membri.

All'inizio di .NET, non avevamo generici. Quindi l' IEnumerableinterfaccia sembrava così:

public interface IEnumerable
{
    IEnumerator GetEnumerator();
}

Questa interfaccia è ciò che ci ha permesso di foreacholtre una raccolta di oggetti, tuttavia abbiamo dovuto lanciare tutti quegli oggetti per usarli correttamente.

Poi vennero i generici. Quando abbiamo ottenuto generici, abbiamo anche avuto una nuova interfaccia:

public interface IEnumerable<T> : IEnumerable
{
    IEnumerator<T> GetEnumerator();
}

Ora non dobbiamo lanciare oggetti mentre li stiamo ripetendo! Woot! Ora, se non fosse consentito nascondere i membri, l'interfaccia dovrebbe apparire in questo modo:

public interface IEnumerable<T> : IEnumerable
{
    IEnumerator<T> GetEnumeratorGeneric();
}

Questo sarebbe un po 'sciocco, perché GetEnumerator()e GetEnumeratorGeneric()in entrambi i casi fanno praticamente esattamente la stessa cosa , ma hanno valori di ritorno leggermente diversi. Sono così simili, infatti, che quasi sempre si desidera impostare automaticamente la forma generica di GetEnumerator, a meno che non si stia lavorando con il codice legacy che è stato scritto prima che i generici fossero introdotti in .NET.

A volte il nascondersi dei membri lascia più spazio a codici cattivi e bug difficili da trovare. Tuttavia a volte è utile, ad esempio quando si desidera modificare un tipo restituito senza interrompere il codice legacy. Questa è solo una di quelle decisioni che i progettisti linguistici devono prendere: disturbiamo gli sviluppatori che hanno legittimamente bisogno di questa funzione e la lasciamo fuori, oppure includiamo questa funzione nel linguaggio e catturiamo la falla da coloro che sono vittime del suo uso improprio?


Mentre formalmente IEnumerable<T>.GetEnumerator()nasconde il IEnumerable.GetEnumerator(), questo è solo perché C # non ha tipi di ritorno covarianti durante l'override. Logicamente si tratta di una sostituzione, completamente in linea con LSP. Nascondersi è quando hai una variabile locale mapin funzione nel file che lo fa using namespace std(in C ++).
Jan Hudec,

2

La tua domanda potrebbe essere letta in due modi: o stai chiedendo l'ambito variabile / funzione in generale, oppure stai ponendo una domanda più specifica sull'ambito in una gerarchia ereditaria. Non hai menzionato l'eredità in modo specifico, ma hai menzionato difficoltà a trovare bug, che suona più come un ambito nel contesto dell'ereditarietà che come un ambito semplice, quindi risponderò a entrambe le domande.

Scope in generale è una buona idea, perché ci consente di focalizzare la nostra attenzione su una parte specifica (si spera piccola) del programma. Perché consente sempre di vincere nomi locali, se leggi solo la parte del programma che rientra in un determinato ambito, allora sai esattamente quali parti sono state definite localmente e cosa è stato definito altrove. O il nome si riferisce a qualcosa di locale, nel qual caso il codice che lo definisce è proprio di fronte a te, oppure è un riferimento a qualcosa al di fuori dell'ambito locale. Se non ci sono riferimenti non locali che potrebbero cambiare da sotto di noi (in particolare variabili globali, che potrebbero essere modificate da qualsiasi luogo), allora possiamo valutare se la parte del programma nell'ambito locale è corretta o meno senza fare riferimento a qualsiasi parte del resto del programma .

Può occasionalmente portare ad alcuni bug, ma compensa più che prevenendo un'enorme quantità di bug altrimenti possibili. Oltre a fare una definizione locale con lo stesso nome di una funzione di libreria (non farlo), non riesco a vedere un modo semplice per introdurre bug con ambito locale, ma l'ambito locale è ciò che consente a molte parti dello stesso programma di utilizzare I come contatore di indice per un loop senza intasarsi a vicenda, e lascia Fred nel corridoio scrivere una funzione che utilizza una stringa denominata str che non ostruirà la stringa con lo stesso nome.

Ho trovato un articolo interessante di Bertrand Meyer che parla del sovraccarico nel contesto dell'eredità. Fa emergere una distinzione interessante, tra ciò che chiama sovraccarico sintattico (che significa che ci sono due cose diverse con lo stesso nome) e sovraccarico semantico (che significa che ci sono due diverse implementazioni della stessa idea astratta). Il sovraccarico semantico andrebbe bene, dal momento che intendevi implementarlo diversamente nella sottoclasse; il sovraccarico sintattico sarebbe la collisione accidentale del nome che ha causato un errore.

La differenza tra sovraccarico in una situazione ereditaria che è intesa e che è un bug è la semantica (il significato), quindi il compilatore non ha modo di sapere se ciò che hai fatto è giusto o sbagliato. In una semplice situazione di ambito, la risposta giusta è sempre la cosa locale, quindi il compilatore può capire qual è la cosa giusta.

Il suggerimento di Bertrand Meyer sarebbe quello di usare un linguaggio come Eiffel, che non consente lo scontro di nomi come questo e costringe il programmatore a rinominare uno o entrambi, evitando così completamente il problema. Il mio suggerimento sarebbe di evitare di usare interamente l'ereditarietà, evitando anche del tutto il problema. Se non puoi o non vuoi fare nessuna di queste cose, ci sono ancora cose che puoi per ridurre la probabilità di avere un problema con l'ereditarietà: segui il LSP (Principio di sostituzione di Liskov), preferisci la composizione rispetto all'eredità, mantieni gerarchie ereditarie superficiali e mantieni piccole le classi in una gerarchia ereditaria. Inoltre, alcune lingue potrebbero essere in grado di emettere un avviso, anche se non genererebbero un errore, come farebbe una lingua come Eiffel.


2

Ecco i miei due centesimi.

I programmi possono essere strutturati in blocchi (funzioni, procedure) che sono unità autonome della logica del programma. Ogni blocco può fare riferimento a "cose" (variabili, funzioni, procedure) usando nomi / identificatori. Questa mappatura dai nomi alle cose è chiamata associazione .

I nomi utilizzati da un blocco rientrano in tre categorie:

  1. Nomi definiti localmente, ad esempio variabili locali, noti solo all'interno del blocco.
  2. Argomenti che sono associati a valori quando viene invocato il blocco e possono essere utilizzati dal chiamante per specificare il parametro input / output del blocco.
  3. Nomi / collegamenti esterni definiti nell'ambiente in cui è contenuto il blocco e rientrano nell'ambito del blocco.

Si consideri ad esempio il seguente programma C.

#include<stdio.h>

void print_double_int(int n)
{
  int d = n * 2;

  printf("%d\n", d);
}

int main(int argc, char *argv[])
{
  print_double_int(4);
}

La funzione print_double_intha un nome locale (variabile locale) de un argomento ne utilizza il nome globale esterno printf, che è nell'ambito ma non definito localmente.

Si noti che printfpotrebbe anche essere passato come argomento:

#include<stdio.h>

void print_double_int(int n, int printf(const char *, ...))
{
  int d = n * 2;

  printf("%d\n", d);
}

int main(int argc, char *argv[])
{
  print_double_int(4, printf);
}

Normalmente, un argomento viene usato per specificare i parametri di input / output di una funzione (procedura, blocco), mentre i nomi globali vengono usati per riferirsi a cose come le funzioni di libreria che "esistono nell'ambiente", e quindi è più conveniente menzionarli solo quando sono necessari. L'uso degli argomenti anziché dei nomi globali è l'idea principale dell'iniezione di dipendenze , che viene utilizzata quando le dipendenze devono essere esplicite invece di essere risolte osservando il contesto.

Un altro uso simile di nomi definiti esternamente può essere trovato nelle chiusure. In questo caso, all'interno del blocco può essere utilizzato un nome definito nel contesto lessicale di un blocco e il valore associato a quel nome (in genere) continuerà ad esistere finché il blocco si riferisce ad esso.

Prendi ad esempio questo codice Scala:

object ClosureExample
{
  def createMultiplier(n: Int) = (m: Int) => m * n

  def main(args: Array[String])
  {
    val multiplier3 = createMultiplier(3)
    val multiplier5 = createMultiplier(5)

    // Prints 6.
    println(multiplier3(2))

    // Prints 10.
    println(multiplier5(2))
  }
}

Il valore restituito della funzione createMultiplierè la chiusura (m: Int) => m * n, che contiene l'argomento me il nome esterno n. Il nome nviene risolto osservando il contesto in cui viene definita la chiusura: il nome è associato all'argomento ndella funzione createMultiplier. Si noti che questa associazione viene creata quando viene creata la chiusura, ovvero quando createMultiplierviene invocata. Quindi il nome nè legato al valore effettivo di un argomento per una particolare invocazione della funzione. Contrastalo con il caso di una funzione di libreria simile printf, che viene risolta dal linker quando viene creato l'eseguibile del programma.

Riassumendo, può essere utile fare riferimento a nomi esterni all'interno di un blocco di codice locale in modo che tu

  • non è necessario / non desidera passare esplicitamente nomi definiti esternamente come argomenti e
  • è possibile congelare i binding in fase di esecuzione quando viene creato un blocco e quindi accedervi successivamente quando viene richiamato il blocco.

L'ombreggiamento arriva quando si considera che in un blocco si è interessati solo a nomi rilevanti che sono definiti nell'ambiente, ad esempio nella printffunzione che si desidera utilizzare. Se per caso si desidera utilizzare un nome locale ( getc, putc, scanf, ...) che è già stato utilizzato in ambiente, è semplice desidera ignorare (shadow) il nome globale. Quindi, quando pensi localmente, non vuoi considerare l'intero contesto (forse molto grande).

Nella direzione opposta, quando si pensa a livello globale, si desidera ignorare i dettagli interni dei contesti locali (incapsulamento). Pertanto è necessario l'ombreggiatura, altrimenti l'aggiunta di un nome globale potrebbe interrompere tutti i blocchi locali che già utilizzavano quel nome.

In conclusione, se si desidera che un blocco di codice faccia riferimento a associazioni definite esternamente, è necessario eseguire l'ombreggiatura per proteggere i nomi locali da quelli globali.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.