Perché i linguaggi di programmazione consentono di nascondere / nascondere variabili e funzioni?

31

Molti dei più popolari linguaggi di programmazione (come C ++, Java, Python ecc.) hanno il concetto di nascondere / ombreggiamento di variabili o funzioni. Quando mi sono imbattuto in clandestinità o oscuramento, sono stati la causa di bug difficili da trovare e non ho mai visto un caso in cui ho ritenuto necessario utilizzare queste funzionalità delle lingue.

Per me, sarebbe meglio non consentire il nascondimento e lo shadowing.

Qualcuno sa di un buon uso di questi concetti?

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

    
posta Simon 21.10.2013 - 14:04
fonte

6 risposte

26

Se non offri nascondigli e ombre, quello che hai è 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 desidera nascondere e nascondere, E si tenta di "proteggere" determinate variabili globali, si crea una situazione in cui il compilatore dice al programmatore "Mi dispiace, Dave, ma tu puoi" usa quel nome, è già in uso. " L'esperienza con COBOL mostra che i programmatori ricorrono quasi immediatamente a parolacce in questa situazione.

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

    
risposta data 21.10.2013 - 15:11
fonte
15

Does anybody know of a good use of these concepts?

Usare identificatori precisi e descrittivi è sempre un buon uso.

Potrei sostenere che il nascondersi delle variabili non causa molti bug dato che avere due variabili con lo stesso nome e lo stesso tipo (cosa si farebbe se il nascondiglio delle variabili fosse disabilitato) potrebbe causare altrettanti bug e / o altrettanto gravi bug. Non so se questo argomento sia corretto , ma è almeno plausibilmente discutibile.

Usando un qualche tipo di notazione ungherese per differenziare i campi contro le variabili locali si 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 il nascondiglio / ombreggiamento piuttosto che disabilitarlo. Implementazione più semplice significa che i compilatori hanno meno probabilità di avere bug. Implementazione più semplice significa che i compilatori impiegano meno tempo a scrivere, causando l'adozione di piattaforme precedenti e più ampie.

    
risposta data 21.10.2013 - 15:18
fonte
7

Per assicurarci di essere 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 virtuale / overridable) 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. Questo è diverso dall'estrazione / sovrascrizione dei membri in cui il membro della classe base si aspetta che la classe derivata definisca una sostituzione e dai modificatori di visibilità / visibilità che "nascondono" un membro dai consumatori al di fuori dell'ambito desiderato.

La breve risposta al motivo per cui è consentito è che non farlo forzerebbe gli sviluppatori a violare diversi principi chiave del design orientato agli oggetti.

Ecco la risposta più lunga; per prima cosa, considera la seguente struttura di classe in un universo alternativo in cui C # non consente l'occultamento 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, così facendo, consentire a Bar di fornire un MyFooString diverso. Tuttavia, non possiamo farlo perché violerebbe la proibizione della realtà alternativa sul nascondersi dei membri. Questo particolare esempio sarebbe pieno di bug ed è un ottimo esempio del perché si potrebbe voler vietarlo; per esempio, quale output della console verrebbe ottenuto se hai 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 ottenuto "Foo" o "Bar" su quest'ultima riga. Avrai sicuramente "Foo" per la prima riga e "Bar" per il secondo, 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 nocivo impedendo il nascondimento delle proprietà. Ora, tu come programmatore hai una vera necessità di fare esattamente questo. Come si aggira la limitazione? Bene, un modo è 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 una barra, dobbiamo anche sapere di usare 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 di 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 un metodo utile "FooMethod" di cui aveva bisogno Bar, ora non puoi ereditare quel metodo; dovresti clonare il codice in Bar o diventare 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 ovvio, e mentre alcune implementazioni delle specifiche del linguaggio O-O equivalgono a poco più di questo, concettualmente è sbagliato; se i consumatori di Bar hanno bisogno di esporre le funzionalità di Foo, Bar dovrebbe essere un Foo, non avere un Foo.

Ovviamente, se controlliamo Foo, possiamo renderlo virtuale, quindi sovrascriverlo. Questa è la migliore pratica concettuale nel nostro universo corrente quando ci si aspetta che un membro venga ignorato e che conservi in qualsiasi universo alternativo che non consentisse il nascondimento:

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

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

Il problema con questo è che l'accesso ai membri virtuali è, sotto il cofano, relativamente più costoso da eseguire, e quindi in genere lo si vuole fare solo quando è necessario. La mancanza di nascondigli, tuttavia, ti costringe a essere pessimista sui membri che un altro coder 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. Inoltre ancora non ti dà il comportamento esatto di nascondere; la stringa sarà sempre "Bar" se l'istanza è una barra. A volte è davvero utile sfruttare i livelli dei dati di stato nascosti, in base al livello di ereditarietà su cui stai lavorando.

In sintesi, consentire ai membri di nascondersi è il minore di questi mali. Il non averlo comporterebbe generalmente atrocità peggiori commesse contro i principi orientati agli oggetti piuttosto che permettere che lo faccia.

    
risposta data 21.10.2013 - 23:00
fonte
2

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

Nei primi giorni di .NET, non avevamo generici. Quindi l'interfaccia IEnumerable assomigliava a questa:

public interface IEnumerable
{
    IEnumerator GetEnumerator();
}

Questa interfaccia è ciò che ci ha permesso di foreach su una raccolta di oggetti, tuttavia abbiamo dovuto eseguire il cast di tutti quegli oggetti per poterli utilizzare correttamente.

Poi sono arrivati i generici. Quando abbiamo ottenuto i generici, abbiamo anche una nuova interfaccia:

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

Ora non dobbiamo lanciare oggetti mentre stiamo iterando attraverso di loro! Woot! Ora, se non fosse permesso nascondere i membri, l'interfaccia avrebbe dovuto assomigliare a qualcosa del tipo:

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

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

A volte il nascondersi di consente più spazio per codice sgradevole e bug difficili da trovare. Tuttavia a volte è utile, ad esempio quando si desidera modificare un tipo di reso senza rompere il codice legacy. Questa è solo una di quelle decisioni che i designer di linguaggi devono prendere: Scomodiamo gli sviluppatori che hanno legittimamente bisogno di questa funzione e la lasciano fuori, oppure includiamo questa funzionalità nella lingua e catturiamo critiche da coloro che sono vittime del suo uso improprio? p>     

risposta data 22.10.2013 - 00:52
fonte
2

La tua domanda potrebbe essere letta in due modi: o stai chiedendo informazioni sull'ambito delle variabili / funzioni in generale, o stai facendo una domanda più specifica sull'ambito in una gerarchia di ereditarietà. Non hai menzionato l'ereditarietà in modo specifico, ma hai menzionato i bug difficili da trovare, che suona più come scope nel contesto dell'eredità che come scope, quindi risponderò a entrambe le domande.

L'ambito 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 ai nomi locali di vincere, se si legge solo la parte del programma che si trova in un dato ambito, allora si sa esattamente quali parti sono state definite localmente e quali sono state definite altrove. O il nome si riferisce a qualcosa di locale, nel qual caso il codice che lo definisce è proprio di fronte a te, o è 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 nello scope locale è corretta o meno senza riferirsi a nessuna parte del resto del programma .

Potrebbe occasionalmente portare a qualche bug, ma è più che compensare impedendo una quantità enorme di bug altrimenti possibili. Oltre a creare 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 scope locale, ma l'ambito locale è ciò che consente a molte parti dello stesso programma di utilizzare io come il contatore di indici per un ciclo senza scombussolarsi a vicenda, e lascia che Fred in fondo scriva una funzione che usa una stringa chiamata str che non rovinerà la stringa con lo stesso nome.

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

La differenza tra l'overloading in una situazione di ereditarietà che è intesa e che è un bug è semantica (il significato), quindi il compilatore non ha modo di sapere se ciò che hai fatto è giusto o sbagliato. In una situazione con scope semplice, 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 ammette conflitti di nomi come questo e costringe il programmatore a rinominare uno o entrambi, evitando così il problema. Il mio suggerimento sarebbe quello di evitare di utilizzare interamente l'ereditarietà, evitando del tutto il problema. Se non puoi o non vuoi fare nessuna di queste cose, ci sono ancora cose che puoi fare per ridurre la probabilità di avere un problema con l'ereditarietà: segui l'LSP (Principio di sostituzione di Liskov), preferisci la composizione all'ereditarietà, tieni le gerarchie di ereditarietà superficiali e mantengono le classi in una gerarchia di ereditarietà ridotta. Inoltre, alcune lingue potrebbero essere in grado di emettere un avvertimento, anche se non emetterebbero un errore, come farebbe una lingua come Eiffel.

    
risposta data 22.10.2013 - 11:36
fonte
2

Ecco i miei due centesimi.

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

I nomi usati da un blocco si dividono in tre categorie:

  1. Nomi definiti localmente, ad es. variabili locali, conosciute solo all'interno del blocco.
  2. Argomenti associati ai valori quando il blocco viene richiamato e può essere utilizzato dal chiamante per specificare il parametro di input / output del blocco.
  3. Nomi / collegamenti esterni definiti nell'ambiente in cui il blocco è contenuto e rientrano nell'ambito del blocco.

Consideriamo 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_int ha un nome locale (variabile locale) d e argomento n e utilizza il nome esterno, globale printf , che è nell'ambito ma non definito localmente.

Si noti che printf potrebbe 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 sono usati per riferirsi a cose come le funzioni di libreria che "esistono nell'ambiente", e quindi è più conveniente per menzionarli solo quando sono necessari. L'uso degli argomenti al posto dei nomi globali è l'idea principale di Iniezione dipendenza , che viene utilizzata quando le dipendenze devono essere rese esplicite anziché risolte osservando il contesto.

Un altro uso simile di nomi definiti esternamente può essere trovato nelle chiusure. In questo caso, un nome definito nel contesto lessicale di un blocco può essere utilizzato all'interno del blocco e il valore associato a quel nome continuerà (tipicamente) a esistere finché il blocco si riferirà 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 di ritorno della funzione createMultiplier è la chiusura (m: Int) => m * n , che contiene l'argomento m e il nome esterno n . Il nome n viene risolto osservando il contesto in cui è definita la chiusura: il nome è associato all'argomento n della funzione createMultiplier . Si noti che questa associazione viene creata quando viene creata la chiusura, ovvero quando viene richiamato createMultiplier . Quindi il nome n è associato al valore effettivo di un argomento per una particolare chiamata della funzione. Contrasta con il caso di una funzione di libreria come 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 locale di codice in modo da

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

Lo shadowing arriva quando consideri che in un blocco ti interessano solo i nomi rilevanti definiti nell'ambiente, ad es. nella funzione printf che si desidera utilizzare. Se per caso si desidera utilizzare un nome locale ( getc , putc , scanf , ...) che è già stato utilizzato nell'ambiente, si desidera semplicemente ignorare (shadow) il nome globale. Quindi, quando si pensa a livello locale, non si vuole considerare l'intero (possibilmente molto grande) contesto.

Nella direzione opposta, quando si pensa globalmente, si vogliono ignorare i dettagli interni dei contesti locali (incapsulamento). Quindi è necessario l'ombreggiamento, altrimenti l'aggiunta di un nome globale potrebbe violare tutti i blocchi locali che già utilizzavano quel nome.

In conclusione, se vuoi un blocco di codice per fare riferimento a binding definiti esternamente, hai bisogno di shadowing per proteggere i nomi locali da quelli globali.

    
risposta data 22.10.2013 - 22:02
fonte

Leggi altre domande sui tag