Por que as linguagens de programação permitem ocultar / ocultar variáveis ​​e funções?


31

Muitas das linguagens de programação mais populares (como C ++, Java, Python etc.) têm o conceito de ocultar / sombrear variáveis ​​ou funções. Quando encontrei esconderijo ou sombreamento, eles causaram erros difíceis de encontrar e nunca vi um caso em que fosse necessário usar esses recursos dos idiomas.

Para mim, seria melhor não permitir ocultar e sombrear.

Alguém sabe de um bom uso desses conceitos?

Atualização:
não estou me referindo ao encapsulamento de membros da classe (membros privados / protegidos).


É por isso que todos os meus fieldnames começar com F.
Pieter B

7
Acho que Eric Lippert teve um bom artigo sobre isso. Ah, espere, aqui está: blogs.msdn.com/b/ericlippert/archive/2008/05/21/…
Lescai Ionel

1
Esclareça sua pergunta. Você está perguntando sobre informações ocultas em geral ou sobre o caso específico descrito no artigo de Lippert, em que uma classe derivada oculta funções da classe base?
Aaron Kurtzhals

Nota importante: Muitos dos erros causados ​​por ocultar / sombrear envolvem mutação (definir a variável errada e perguntar por que a alteração "nunca acontece", por exemplo). Ao trabalhar principalmente com referências imutáveis, ocultar / sombrear causa muito menos problemas e é muito menos provável que cause bugs.
Jack

Respostas:


26

Se você não permitir ocultar e sombrear, o que você tem é um idioma no qual todas as variáveis ​​são globais.

Isso é claramente pior do que permitir variáveis ​​ou funções locais que podem ocultar variáveis ​​ou funções globais.

Se você não permitir ocultar e sombrear E tentar "proteger" determinadas variáveis ​​globais, criará uma situação em que o compilador informará o programador "Sinto muito, Dave, mas você não pode usar esse nome, ele já está em uso. . " A experiência com o COBOL mostra que os programadores quase imediatamente recorrem a palavrões nessa situação.

A questão fundamental não é ocultar / sombrear, mas variáveis ​​globais.


19
Outra desvantagem da proibição de sombreamento é que a adição de uma variável global pode quebrar o código porque a variável já havia sido usada em um bloco local.
Giorgio

19
"Se você não permite ocultar e sombrear, o que você tem é uma linguagem na qual todas as variáveis ​​são globais." - não necessariamente: você pode ter variáveis ​​de escopo sem sombreamento, e você explicou isso.
Thiago Silva

@ThiagoSilva: E então seu idioma tem que ter uma maneira de dizer ao compilador que este módulo É permitido o acesso que "Frammis" variável do módulo. Você permite que alguém esconda / sombreie um objeto que ele nem sabe que existe, ou você conta a ele para dizer por que ele não pode usar esse nome?
John R. Strohm

9
@ Phil, desculpe-me por discordar de você, mas o OP perguntou sobre "ocultação / sombreamento de variáveis ​​ou funções", e as palavras "pai", "filho", "classe" e "membro" não aparecem em nenhuma parte da pergunta. Isso pareceria fazer uma pergunta geral sobre o escopo do nome.
John R. Strohm

3
@dylnmc, eu não esperava viver o suficiente para encontrar uma pessoa jovem o suficiente para não ter uma referência óbvia a "2001: Uma Odisséia no Espaço".
John R. Strohm

15

Alguém sabe de um bom uso desses conceitos?

Usar identificadores precisos e descritivos é sempre um bom uso.

Eu poderia argumentar que a ocultação de variáveis ​​não causa muitos erros, uma vez que ter duas variáveis ​​com nomes muito semelhantes dos mesmos / tipos semelhantes (o que você faria se a ocultação de variáveis ​​não fosse permitida) provavelmente causará tantos erros e / ou erros graves. Não sei se esse argumento está correto , mas é pelo menos plausivelmente discutível.

O uso de algum tipo de notação húngara para diferenciar campos versus variáveis ​​locais contorna isso, mas tem seu próprio impacto na manutenção (e na sanidade do programador).

E (provavelmente o motivo pelo qual o conceito é conhecido em primeiro lugar) é muito mais fácil para as linguagens implementar ocultação / sombreamento do que desaprovar. Uma implementação mais fácil significa que é menos provável que os compiladores tenham bugs. A implementação mais fácil significa que os compiladores levam menos tempo para escrever, causando uma adoção mais ampla e anterior da plataforma.


3
Na verdade, não, NÃO é mais fácil implementar ocultação e sombreamento. Na verdade, é mais fácil implementar "todas as variáveis ​​são globais". Você só precisa de um espaço para nome, e SEMPRE exporta o nome, em vez de ter vários espaços para nome e decidir por cada nome se deseja exportá-lo.
John R. Strohm

5
@ JohnR.Strohm - Claro, mas assim que você tem algum tipo de escopo (leia-se: classes), então os escopos ocultam os escopos inferiores, e existem de graça.
Telastyn

Escopo e classes são coisas diferentes. Com exceção do BASIC, todas as linguagens em que programei têm escopo, mas nem todas têm qualquer conceito de classe ou objeto.
Michael Shaw

@michaelshaw - é claro, eu deveria ter sido mais claro.
Telastyn

7

Apenas para garantir que estamos na mesma página, o método "oculto" é quando uma classe derivada define um membro com o mesmo nome que um membro da classe base (que, se for um método / propriedade, não é marcado como virtual / substituível ) e, quando invocado de uma instância da classe derivada no "contexto derivado", o membro derivado é usado, enquanto que, se invocado pela mesma instância no contexto de sua classe base, o membro da classe base é usado. Isso é diferente da abstração / substituição do membro, na qual o membro da classe base espera que a classe derivada defina uma substituição e dos modificadores de escopo / visibilidade que "ocultam" um membro dos consumidores fora do escopo desejado.

A resposta curta para por que é permitido é que isso não forçaria os desenvolvedores a violar vários princípios fundamentais do design orientado a objetos.

Aqui está a resposta mais longa; primeiro, considere a seguinte estrutura de classes em um universo alternativo em que o C # não permite ocultar membros:

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";}}
}

Queremos descomentar o membro em Bar e, ao fazê-lo, permitir que Bar forneça um MyFooString diferente. No entanto, não podemos fazê-lo porque isso violaria a proibição de realidade alternativa ao esconderijo de membros. Este exemplo em particular estaria repleto de bugs e é um excelente exemplo de por que você pode querer bani-lo; por exemplo, qual saída do console você obteria se fizesse o seguinte?

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

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

No topo da minha cabeça, na verdade não tenho certeza se você conseguiria "Foo" ou "Bar" nessa última linha. Você definitivamente obteria "Foo" para a primeira linha e "Bar" para a segunda, embora todas as três variáveis ​​façam referência exatamente à mesma instância com exatamente o mesmo estado.

Assim, os designers da linguagem, em nosso universo alternativo, desencorajam esse código obviamente ruim, impedindo a ocultação de propriedades. Agora, você como codificador tem uma necessidade genuína de fazer exatamente isso. Como você contorna a limitação? Bem, uma maneira é nomear a propriedade de Bar de maneira diferente:

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

Perfeitamente legal, mas não é o comportamento que queremos. Uma instância de Bar sempre produzirá "Foo" para a propriedade MyFooString, quando desejamos que ela produza "Bar". Não apenas precisamos saber que nosso IFoo é especificamente uma barra, também precisamos saber para usar o acessador diferente.

Também podemos, de maneira bastante plausível, esquecer o relacionamento pai-filho e implementar a interface diretamente:

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

Para este exemplo simples, é uma resposta perfeita, desde que você se preocupe apenas com Foo e Bar serem ambos IFoos. O código de uso de alguns exemplos falharia em compilar porque uma barra não é um Foo e não pode ser atribuída como tal. No entanto, se o Foo tivesse algum método útil "FooMethod" que o Bar precisava, agora você não poderá herdar esse método; você precisaria clonar seu código em Bar ou ser criativo:

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

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

Este é um truque óbvio e, embora algumas implementações das especificações da linguagem OO cheguem a pouco mais do que isso, conceitualmente está errado; se os consumidores de Bar necessidade de expor a funcionalidade do Foo, Bar deve ser um Foo, não têm uma Foo.

Obviamente, se controlamos o Foo, podemos torná-lo virtual e substituí-lo. Esta é a melhor prática conceitual em nosso universo atual, quando se espera que um membro seja substituído, e se manteria em qualquer universo alternativo que não permitisse ocultar:

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

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

O problema disso é que o acesso de membro virtual é, relativamente, mais caro de executar e, portanto, você normalmente deseja fazê-lo apenas quando necessário. A falta de ocultação, no entanto, força você a ser pessimista em relação aos membros que outro codificador que não controla seu código-fonte pode querer reimplementar; a "melhor prática" para qualquer classe não selada seria tornar tudo virtual, a menos que você não desejasse especificamente. Também ainda não fornece o comportamento exato de se esconder; a cadeia sempre será "Bar" se a instância for uma barra. Às vezes, é realmente útil aproveitar as camadas de dados de estado oculto, com base no nível de herança em que você está trabalhando.

Em resumo, permitir a ocultação de membros é o menor desses males. Não tê-lo geralmente levaria a piores atrocidades cometidas contra princípios orientados a objetos do que permitir.


+1 para abordar a questão real. Um bom exemplo de uso no mundo real de ocultação de membros é a interface IEnumerablee IEnumerable<T>, descrita na postagem de Eric Libbert no blog sobre o tópico.
Phil

Substituir não está oculto. Não concordo com @Phil que isso resolva a questão.
Jan Hudec

Meu argumento era que substituir seria um substituto para ocultar quando ocultar não é uma opção. Concordo, não está escondido, e digo isso no primeiro parágrafo. Nenhuma das soluções alternativas para o meu cenário de realidade alternativa, sem ocultação em C #, está oculta; essa é a questão.
Keiths

Não gosto do seu uso de sombreamento / ocultação. Os principais bons usos que vejo são (1) discutir a situação em que uma nova versão de uma classe base inclui um membro que entra em conflito com o código do consumidor projetado em torno de uma versão mais antiga [feia, mas necessária]; (2) fingir coisas como covariância do tipo retorno; (3) lidar com casos em que um método de classe base é exigível em um subtipo específico, mas não é útil . O LSP exige o primeiro, mas não o último se o contrato da classe base especificar que alguns métodos podem não ser úteis em algumas condições.
Supercat

2

Honestamente, Eric Lippert, o principal desenvolvedor da equipe de compiladores C #, explica muito bem (obrigado Lescai Ionel pelo link). .NET IEnumerablee IEnumerable<T>interfaces são bons exemplos de quando a ocultação de membros é útil.

Nos primeiros dias do .NET, não tínhamos genéricos. Portanto, a IEnumerableinterface ficou assim:

public interface IEnumerable
{
    IEnumerator GetEnumerator();
}

Essa interface é o que nos permitiu foreachsobre uma coleção de objetos, no entanto, tivemos que converter todos esses objetos para usá-los adequadamente.

Então vieram os genéricos. Quando adquirimos genéricos, também obtivemos uma nova interface:

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

Agora não precisamos lançar objetos enquanto estamos iterando através deles! Woot! Agora, se a ocultação de membros não fosse permitida, a interface teria que se parecer com algo assim:

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

Isso seria meio bobo, porque GetEnumerator()e GetEnumeratorGeneric()em ambos os casos fazem exatamente a mesma coisa , mas eles têm valores de retorno ligeiramente diferentes. Eles são tão parecidos, de fato, que você quase sempre deseja usar como padrão a forma genérica GetEnumerator, a menos que esteja trabalhando com código legado que foi escrito antes da introdução de genéricos no .NET.

Às vezes membro do esconderijo não permitir mais espaço para o código desagradável e erros difíceis de encontrar. No entanto, às vezes é útil, como quando você deseja alterar um tipo de retorno sem quebrar o código herdado. Essa é apenas uma dessas decisões que os designers de linguagem precisam tomar: Incomodamos os desenvolvedores que legitimamente precisam desse recurso e o deixam de fora ou incluímos esse recurso no idioma e capturamos críticas daqueles que são vítimas de uso indevido?


Embora formalmente IEnumerable<T>.GetEnumerator()oculte IEnumerable.GetEnumerator(), isso ocorre apenas porque o C # não possui tipos de retorno covariantes ao substituir. Logicamente, é uma substituição, totalmente alinhada com o LSP. Ocultar é quando você tem uma variável local mapem função no arquivo que faz using namespace std(em C ++).
Jan Hudec

2

Sua pergunta pode ser lida de duas maneiras: você está perguntando sobre o escopo de variável / função em geral ou está fazendo uma pergunta mais específica sobre o escopo em uma hierarquia de herança. Você não mencionou especificamente a herança, mas mencionou erros difíceis de encontrar, o que parece mais o escopo no contexto da herança do que o escopo simples; portanto, responderei às duas perguntas.

O escopo, em geral, é uma boa idéia, porque nos permite focar nossa atenção em uma parte específica (que se espera que seja pequena) do programa. Como ele permite que os nomes locais sempre ganhem, se você ler apenas a parte do programa que está em um determinado escopo, saberá exatamente quais partes foram definidas localmente e o que foi definido em outro lugar. O nome se refere a algo local; nesse caso, o código que o define está bem à sua frente ou é uma referência a algo fora do escopo local. Se não houver referências não locais que possam mudar sob nós (especialmente variáveis ​​globais, que podem ser alteradas de qualquer lugar), podemos avaliar se a parte do programa no escopo local está correta ou não sem consultar a qualquer parte do restante do programa .

Ocasionalmente, pode levar a alguns erros, mas é mais do que compensado, impedindo uma quantidade enorme de erros possíveis. Além de fazer uma definição local com o mesmo nome de uma função de biblioteca (não faça isso), não vejo uma maneira fácil de introduzir bugs com escopo local, mas escopo local é o que permite que muitas partes do mesmo programa usem i como o contador de índice de um loop sem bater um no outro, e permite que Fred no corredor escreva uma função que usa uma string chamada str que não irá bater na sua string com o mesmo nome.

eu encontrei um artigo interessante de Bertrand Meyer que discute a sobrecarga no contexto da herança. Ele traz uma distinção interessante entre o que ele chama de sobrecarga sintática (significando que existem duas coisas diferentes com o mesmo nome) e sobrecarga semântica (significando que existem duas implementações diferentes da mesma idéia abstrata). Sobrecarga semântica seria boa, pois você pretendia implementá-la de maneira diferente na subclasse; sobrecarga sintática seria a colisão acidental de nomes que causou um bug.

A diferença entre sobrecarga em uma situação de herança que se destina e que é um bug é semântica (o significado); portanto, o compilador não tem como saber se o que você fez é certo ou errado. Em uma situação de escopo simples, a resposta certa é sempre a coisa local, para que o compilador possa descobrir qual é a coisa certa.

A sugestão de Bertrand Meyer seria usar uma linguagem como Eiffel, que não permite conflitos de nomes como esse e força o programador a renomear um ou ambos, evitando assim o problema completamente. Minha sugestão seria evitar o uso completo da herança, evitando também o problema por completo. Se você não pode ou não quer fazer uma dessas coisas, ainda há coisas a serem reduzidas para reduzir a probabilidade de ter um problema de herança: siga o LSP (Princípio de Substituição de Liskov), prefira composição ao invés de herança, mantenha suas hierarquias de herança são rasas e mantenha as classes em uma hierarquia de herança pequena. Além disso, alguns idiomas podem emitir um aviso, mesmo que não apresentem erro, como um idioma como o Eiffel faria.


2

Aqui estão meus dois centavos.

Os programas podem ser estruturados em blocos (funções, procedimentos) que são unidades independentes da lógica do programa. Cada bloco pode se referir a "coisas" (variáveis, funções, procedimentos) usando nomes / identificadores. Esse mapeamento de nomes para coisas é chamado de ligação .

Os nomes usados ​​por um bloco se enquadram em três categorias:

  1. Nomes definidos localmente, por exemplo, variáveis ​​locais, que são conhecidas apenas dentro do bloco.
  2. Argumentos que estão vinculados a valores quando o bloco é chamado e podem ser usados ​​pelo chamador para especificar o parâmetro de entrada / saída do bloco.
  3. Nomes / ligações externas que são definidos no ambiente em que o bloco está contido e estão no escopo dentro do bloco.

Considere, por exemplo, o seguinte programa 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);
}

A função print_double_inttem um nome local (variável local) de argumento n, e usa o nome global externoprintf , que está no escopo, mas não está definido localmente.

Observe que printftambém pode ser passado como argumento:

#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, um argumento é usado para especificar parâmetros de entrada / saída de uma função (procedimento, bloco), enquanto nomes globais são usados ​​para se referir a coisas como funções de biblioteca que "existem no ambiente" e, portanto, é mais conveniente mencioná-las somente quando eles são necessários. Usar argumentos em vez de nomes globais é a ideia principal da injeção de dependência , usada quando as dependências devem ser explicitadas em vez de serem resolvidas observando o contexto.

Outro uso semelhante de nomes definidos externamente pode ser encontrado nos fechamentos. Nesse caso, um nome definido no contexto lexical de um bloco pode ser usado dentro do bloco, e o valor associado a esse nome (normalmente) continuará a existir enquanto o bloco se referir a ele.

Tomemos, por exemplo, este código 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))
  }
}

O valor de retorno da função createMultiplieré o fechamento (m: Int) => m * n, que contém o argumento me o nome externo n. O nome né resolvido observando o contexto em que o fechamento é definido: o nome é vinculado ao argumento nda função createMultiplier. Observe que essa ligação é criada quando o fechamento é criado, ou seja, quando createMultiplieré chamado. Portanto, o nome nestá vinculado ao valor real de um argumento para uma chamada específica da função. Compare isso com o caso de uma função de biblioteca como printf, que é resolvida pelo vinculador quando o executável do programa é criado.

Resumindo, pode ser útil consultar nomes externos dentro de um bloco de código local para que você

  • não precisa / deseja passar nomes definidos externamente explicitamente como argumentos e
  • você pode congelar ligações em tempo de execução quando um bloco é criado e acessá-lo mais tarde quando o bloco é chamado.

O sombreamento aparece quando você considera que, em um bloco, você está interessado apenas em nomes relevantes definidos no ambiente, por exemplo, na printffunção que deseja usar. Se por acaso você quiser usar um nome local ( getc, putc,scanf , ...) que já foi usado no ambiente, você simples deseja ignorar (sombra) o nome global. Portanto, ao pensar localmente, você não deseja considerar todo o contexto (possivelmente muito grande).

Na outra direção, ao pensar globalmente, você deseja ignorar os detalhes internos dos contextos locais (encapsulamento). Portanto, você precisa sombrear, caso contrário, a adição de um nome global poderia interromper todos os blocos locais que já estavam usando esse nome.

Bottom line, se você deseja que um bloco de código se refira a ligações definidas externamente, é necessário sombreamento para proteger nomes locais de nomes globais.

Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.