¿Por qué los lenguajes de programación permiten sombrear / ocultar variables y funciones?

31

Muchos de los lenguajes de programación más populares (como C ++, Java, Python, etc.) tienen el concepto de ocultar / sombrear variables o funciones. Cuando me he encontrado oculto o sombreado, han sido la causa de errores difíciles de encontrar y nunca he visto un caso en el que me pareció necesario utilizar estas características de los idiomas.

A mí me parecería mejor no permitir que se esconda y se sombree.

¿Alguien sabe de un buen uso de estos conceptos?

Actualización: no
me estoy refiriendo a la encapsulación de miembros de la clase (miembros privados / protegidos).

Simon
fuente
Es por eso que todos mis nombres de campo comienzan con F.
Pieter B
77
Creo que Eric Lippert tuvo un buen artículo sobre esto. Oh, espera, aquí está: blogs.msdn.com/b/ericlippert/archive/2008/05/21/…
Lescai Ionel
1
Por favor aclara tu pregunta. ¿Está preguntando acerca de la ocultación de información en general, o sobre el caso específico descrito en el artículo de Lippert donde una clase derivada oculta funciones de la clase base?
Aaron Kurtzhals
Nota importante: muchos de los errores causados ​​por ocultar / sombrear involucran mutación (establecer la variable incorrecta y preguntarse por qué el cambio "nunca ocurre", por ejemplo). Cuando se trabaja principalmente con referencias inmutables, ocultar / sombrear causa muchos menos problemas y es mucho menos probable que cause errores.
Jack

Respuestas:

26

Si no permite ocultar y sombrear, lo que tiene es un lenguaje en el que todas las variables son globales.

Eso es claramente peor que permitir variables o funciones locales que pueden ocultar variables o funciones globales.

Si no permitir ocultar y sombreado, y se intenta "proteger" a ciertas variables globales, se crea una situación en la que el compilador dice que el programador "Lo siento, Dave, pero no se puede utilizar ese nombre, que ya está en uso ". La experiencia con COBOL muestra que los programadores recurren casi de inmediato a las blasfemias en esta situación.

El problema fundamental no es ocultar / sombrear, sino variables globales.

John R. Strohm
fuente
19
Otra desventaja de prohibir el sombreado es que agregar una variable global podría romper el código porque la variable ya se había utilizado en un bloque local.
Giorgio
19
"Si no permite ocultarse y sombrearse, lo que tiene es un lenguaje en el que todas las variables son globales". - no necesariamente: puede tener variables de ámbito sin sombrear, y lo explicó.
Thiago Silva
@ThiagoSilva: Y entonces la lengua tiene que tener una forma de decirle al compilador que este módulo SE permitió el acceso esa variable "Frammis" del módulo. ¿Permites que alguien oculte / oculte un objeto que ni siquiera sabe que existe, o le dices al respecto para decirle por qué no se le permite usar ese nombre?
John R. Strohm
99
@ Phil, discúlpeme por no estar de acuerdo con usted, pero el OP le preguntó sobre "ocultar / sombrear variables o funciones", y las palabras "padre", "niño", "clase" y "miembro" no aparecen en ninguna parte de su pregunta. Eso parece hacer que sea una pregunta general sobre el alcance del nombre.
John R. Strohm
3
@dylnmc, no esperaba vivir lo suficiente como para encontrarme con un whippersnapper lo suficientemente joven como para no obtener una referencia obvia de "2001: Una odisea del espacio".
John R. Strohm
15

¿Alguien sabe de un buen uso de estos conceptos?

Usar identificadores descriptivos precisos es siempre un buen uso.

Podría argumentar que el ocultamiento de variables no causa muchos errores, ya que tener dos variables con nombres muy similares del mismo tipo / similares (lo que haría si no se permitiera el ocultamiento de variables) es probable que provoque tantos errores y / o solo errores graves No sé si ese argumento es correcto , pero al menos es plausiblemente discutible.

El uso de algún tipo de notación húngara para diferenciar los campos de las variables locales evita esto, pero tiene su propio impacto en el mantenimiento (y la cordura del programador).

Y (quizás la razón más probable por la que se conoce el concepto en primer lugar) es mucho más fácil para los idiomas implementar el ocultamiento / sombreado que no permitirlo. Una implementación más fácil significa que los compiladores tienen menos probabilidades de tener errores. Una implementación más fácil significa que los compiladores tardan menos en escribir, lo que provoca una adopción de plataforma más temprana y más amplia.

Telastyn
fuente
3
En realidad, no, NO es más fácil implementar el ocultamiento y el sombreado. En realidad, es más fácil implementar "todas las variables son globales". Solo necesita un espacio de nombres y SIEMPRE exporta el nombre, en lugar de tener múltiples espacios de nombres y tener que decidir para cada nombre si exportarlo.
John R. Strohm
55
@ JohnR.Strohm - Claro, pero tan pronto como tenga algún tipo de alcance (léase: clases), entonces hacer que los ámbitos oculten los ámbitos inferiores estará allí de forma gratuita.
Telastyn
El alcance y las clases son cosas diferentes. Con la excepción de BASIC, cada lenguaje que he programado tiene alcance, pero no todos tienen ningún concepto de clases u objetos.
Michael Shaw
@michaelshaw: por supuesto, debería haber sido más claro.
Telastyn
7

Solo para asegurarnos de que estamos en la misma página, el método "ocultar" es cuando una clase derivada define un miembro del mismo nombre como uno en la clase base (que, si es un método / propiedad, no se marca virtual / reemplazable ), y cuando se invoca desde una instancia de la clase derivada en "contexto derivado", se usa el miembro derivado, mientras que si es invocado por la misma instancia en el contexto de su clase base, se usa el miembro de la clase base. Esto es diferente de la abstracción / anulación de miembros donde el miembro de la clase base espera que la clase derivada defina un reemplazo, y de los modificadores de alcance / visibilidad que "ocultan" un miembro de los consumidores fuera del alcance deseado.

La respuesta breve a por qué está permitido es que no hacerlo obligaría a los desarrolladores a violar varios principios clave del diseño orientado a objetos.

Aquí está la respuesta más larga; primero, considere la siguiente estructura de clase en un universo alternativo donde C # no permite ocultar miembros:

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 al miembro en Bar y, al hacerlo, permitir que Bar proporcione un MyFooString diferente. Sin embargo, no podemos hacerlo porque violaría la prohibición de la realidad alternativa de ocultar miembros. Este ejemplo particular sería abundante para los errores y es un excelente ejemplo de por qué es posible que desee prohibirlo; por ejemplo, ¿qué salida de consola obtendrías si hicieras lo siguiente?

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

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

Fuera de mi cabeza, en realidad no estoy seguro de si obtendrías "Foo" o "Bar" en esa última línea. Definitivamente obtendría "Foo" para la primera línea y "Bar" para la segunda, a pesar de que las tres variables hacen referencia exactamente a la misma instancia con exactamente el mismo estado.

Entonces, los diseñadores del lenguaje, en nuestro universo alternativo, desalientan este código obviamente malo al evitar la ocultación de propiedades. Ahora, usted como codificador tiene una verdadera necesidad de hacer exactamente esto. ¿Cómo esquivas la limitación? Bueno, una forma es nombrar la propiedad de Bar de manera diferente:

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

Perfectamente legal, pero no es el comportamiento que queremos. Una instancia de Bar siempre producirá "Foo" para la propiedad MyFooString, cuando queramos que produzca "Bar". No solo tenemos que saber que nuestro IFoo es específicamente un Bar, también tenemos que saber usar los diferentes accesorios.

También podríamos, muy plausiblemente, olvidar la relación padre-hijo e implementar la interfaz directamente:

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

Para este simple ejemplo, es una respuesta perfecta, siempre y cuando solo le importe que Foo y Bar sean ambos IFoos. El código de uso de un par de ejemplos no podría compilarse porque un Bar no es un Foo y no se puede asignar como tal. Sin embargo, si Foo tenía algún método útil "FooMethod" que Bar necesitaba, ahora no puede heredar ese método; tendrías que clonar su código en Bar o ser creativo:

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

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

Este es un truco obvio, y aunque algunas implementaciones de las especificaciones del lenguaje OO ascienden a poco más que esto, conceptualmente está mal; si los consumidores de bar necesidad de exponer la funcionalidad de Foo, Bar debería ser un Foo, no tiene un Foo.

Obviamente, si controlamos Foo, podemos hacerlo virtual y luego anularlo. Esta es la mejor práctica conceptual en nuestro universo actual cuando se espera que un miembro sea anulado, y se mantendría en cualquier universo alternativo que no permitiera ocultarse:

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

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

El problema con esto es que el acceso virtual de los miembros es, bajo el capó, relativamente más costoso de realizar, por lo que normalmente solo desea hacerlo cuando lo necesita. La falta de ocultación, sin embargo, te obliga a ser pesimista sobre los miembros que otro codificador que no controla tu código fuente podría querer volver a implementar; la "mejor práctica" para cualquier clase no sellada sería hacer que todo sea virtual a menos que específicamente no lo desees. También todavía no le otorga el comportamiento exacto de su escondite; la cadena siempre será "Bar" si la instancia es una barra. A veces es realmente útil aprovechar las capas de datos de estado ocultos, según el nivel de herencia en el que está trabajando.

En resumen, permitir que los miembros se escondan es el menor de estos males. No tenerlo generalmente conduciría a peores atrocidades cometidas contra principios orientados a objetos que permitirlo.

KeithS
fuente
+1 para abordar la pregunta real. Un buen ejemplo del uso en el mundo real de la ocultación de miembros es la interfaz IEnumerabley IEnumerable<T>, que se describe en la publicación del blog de Eric Libbert sobre el tema.
Phil
Anular no es esconder. No estoy de acuerdo con @Phil en que esto aborde la pregunta.
Jan Hudec
Mi punto era que anular sería un sustituto de esconderse cuando esconderse no es una opción. Estoy de acuerdo, no se esconde, y lo digo en el primer párrafo. Ninguna de las soluciones a mi escenario de realidad alternativa de no esconderse en C # se esconde; ese es el punto.
KeithS
No me gustan tus usos de sombrear / esconderte. Los principales buenos usos que veo son (1) analizar la situación en la que una nueva versión de una clase base incluye un miembro que entra en conflicto con el código del consumidor diseñado en torno a una versión anterior [feo pero necesario]; (2) falsificar cosas como la covarianza de tipo retorno; (3) tratar casos en los que un método de clase base es invocable en un subtipo particular pero no es útil . El LSP requiere el primero, pero no requiere el último si el contrato de clase base especifica que algunos métodos pueden no ser útiles en algunas condiciones.
supercat
2

Honestamente, Eric Lippert, el desarrollador principal del equipo del compilador de C #, lo explica bastante bien (gracias Lescai Ionel por el enlace). Las interfaces IEnumerabley .NET IEnumerable<T>son buenos ejemplos de cuándo es útil la ocultación de miembros.

En los primeros días de .NET, no teníamos genéricos. Entonces la IEnumerableinterfaz se veía así:

public interface IEnumerable
{
    IEnumerator GetEnumerator();
}

Esta interfaz es lo que nos permitió foreachsobre una colección de objetos, sin embargo, tuvimos que lanzar todos esos objetos para poder usarlos correctamente.

Luego vinieron los genéricos. Cuando obtuvimos genéricos, también obtuvimos una nueva interfaz:

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

¡Ahora no tenemos que lanzar objetos mientras los iteramos! Woot! Ahora, si no se permitiera ocultar miembros, la interfaz tendría que verse así:

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

Esto sería un poco tonto, porque GetEnumerator()y GetEnumeratorGeneric()en ambos casos hacen exactamente lo mismo , pero tienen valores de retorno ligeramente diferentes. De hecho, son tan similares que casi siempre desea utilizar la forma genérica de forma predeterminada GetEnumerator, a menos que esté trabajando con código heredado que se escribió antes de que se introdujeran los genéricos en .NET.

A veces escondite miembro de no permitir más espacio para el código desagradable y errores difíciles de encontrar. Sin embargo, a veces es útil, como cuando desea cambiar un tipo de retorno sin romper el código heredado. Esa es solo una de esas decisiones que deben tomar los diseñadores de idiomas: ¿incomodamos a los desarrolladores que legítimamente necesitan esta característica y la dejamos de lado, o incluimos esta característica en el lenguaje y atrapamos críticas de aquellos que son víctimas de su mal uso?

Phil
fuente
Si bien formalmente IEnumerable<T>.GetEnumerator()oculta IEnumerable.GetEnumerator(), esto se debe solo a que C # no tiene tipos de retorno covariantes cuando se anula. Lógicamente es una anulación, totalmente en línea con LSP. Ocultar es cuando tienes una variable local mapen función en un archivo que sí using namespace std(en C ++).
Jan Hudec
2

Su pregunta podría leerse de dos maneras: o está preguntando sobre el alcance de la variable / función en general, o está haciendo una pregunta más específica sobre el alcance en una jerarquía de herencia. No mencionó la herencia específicamente, pero mencionó errores difíciles de encontrar, lo que parece más un alcance en el contexto de la herencia que un alcance simple, por lo que responderé ambas preguntas.

El alcance en general es una buena idea, ya que nos permite centrar nuestra atención en una parte específica (con suerte pequeña) del programa. Debido a que permite que los nombres locales siempre ganen, si lee solo la parte del programa que se encuentra en un alcance determinado, entonces sabrá exactamente qué partes se definieron localmente y qué se definió en otro lugar. O el nombre se refiere a algo local, en cuyo caso el código que lo define está justo en frente de usted, o es una referencia a algo fuera del alcance local. Si no hay referencias no locales que puedan cambiar por debajo de nosotros (especialmente las variables globales, que podrían cambiarse desde cualquier lugar), entonces podemos evaluar si la parte del programa en el ámbito local es correcta o no sin consultar a cualquier parte del resto del programa .

Es posible que ocasionalmente ocasione algunos errores, pero compensa más que nada evitando una enorme cantidad de errores posibles. Aparte de hacer una definición local con el mismo nombre que una función de biblioteca (no hagas eso), no puedo ver una manera fácil de introducir errores con alcance local, pero el alcance local es lo que permite que muchas partes del mismo programa usen i como el contador de índice para un bucle sin golpearse entre sí, y permite que Fred al final del pasillo escriba una función que utiliza una cadena llamada str que no golpeará su cadena con el mismo nombre.

Encontré un artículo interesante de Bertrand Meyer que analiza la sobrecarga en el contexto de la herencia. Trae una distinción interesante, entre lo que él llama sobrecarga sintáctica (lo que significa que hay dos cosas diferentes con el mismo nombre) y sobrecarga semántica (lo que significa que hay dos implementaciones diferentes de la misma idea abstracta). La sobrecarga semántica estaría bien, ya que pretendía implementarla de manera diferente en la subclase; la sobrecarga sintáctica sería la colisión accidental de nombres que causó un error.

La diferencia entre sobrecargar en una situación de herencia que se pretende y que es un error es la semántica (el significado), por lo que el compilador no tiene forma de saber si lo que hizo es correcto o incorrecto. En una situación de alcance simple, la respuesta correcta es siempre lo local, por lo que el compilador puede descubrir qué es lo correcto.

La sugerencia de Bertrand Meyer sería utilizar un lenguaje como Eiffel, que no permite conflictos de nombres como este y obliga al programador a cambiar el nombre de uno o ambos, evitando así el problema por completo. Mi sugerencia sería evitar el uso de la herencia por completo, también evitando el problema por completo. Si no puede o no quiere hacer ninguna de esas cosas, todavía hay cosas que puede hacer para reducir la probabilidad de tener un problema de herencia: siga el LSP (Principio de sustitución de Liskov), prefiera la composición sobre la herencia, mantenga sus jerarquías de herencia son superficiales y mantenga las clases en una jerarquía de herencia pequeña. Además, algunos idiomas pueden emitir una advertencia, incluso si no emitieran un error, como lo haría un idioma como Eiffel.

Michael Shaw
fuente
2

Aquí están mis dos centavos.

Los programas pueden estructurarse en bloques (funciones, procedimientos) que son unidades independientes de la lógica del programa. Cada bloque puede referirse a "cosas" (variables, funciones, procedimientos) usando nombres / identificadores. Este mapeo de nombres a cosas se llama enlace .

Los nombres utilizados por un bloque se dividen en tres categorías:

  1. Nombres definidos localmente, por ejemplo, variables locales, que solo se conocen dentro del bloque.
  2. Argumentos que están vinculados a valores cuando se invoca el bloque y el llamador puede usarlos para especificar el parámetro de entrada / salida del bloque.
  3. Nombres / enlaces externos que se definen en el entorno en el que está contenido el bloque y están dentro del alcance del bloque.

Considere, por ejemplo, el siguiente 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);
}

La función print_double_inttiene un nombre local (variable local) dy un argumento n, y usa el nombre global externo printf, que está dentro del alcance pero no está definido localmente.

Tenga en cuenta que printftambién podría pasarse 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, se usa un argumento para especificar los parámetros de entrada / salida de una función (procedimiento, bloque), mientras que los nombres globales se usan para referirse a cosas como las funciones de la biblioteca que "existen en el entorno" y, por lo tanto, es más conveniente mencionarlas solo cuando son necesarios. El uso de argumentos en lugar de nombres globales es la idea principal de la inyección de dependencias , que se utiliza cuando las dependencias deben hacerse explícitas en lugar de resolverse mirando el contexto.

Otro uso similar de nombres definidos externamente se puede encontrar en los cierres. En este caso, se puede usar un nombre definido en el contexto léxico de un bloque dentro del bloque, y el valor vinculado a ese nombre (típicamente) continuará existiendo mientras el bloque se refiera a él.

Tomemos por ejemplo 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))
  }
}

El valor de retorno de la función createMultiplieres el cierre (m: Int) => m * n, que contiene el argumento my el nombre externo n. El nombre nse resuelve mirando el contexto en el que se define el cierre: el nombre está vinculado al argumento nde la función createMultiplier. Tenga en cuenta que este enlace se crea cuando se crea el cierre, es decir, cuando createMultiplierse invoca. Por lo tanto, el nombre nestá vinculado al valor real de un argumento para una invocación particular de la función. Contraste esto con el caso de una función de biblioteca como printf, que se resuelve mediante el vinculador cuando se construye el ejecutable del programa.

En resumen, puede ser útil hacer referencia a nombres externos dentro de un bloque de código local para que pueda

  • no necesita / desea pasar nombres definidos externamente explícitamente como argumentos, y
  • puede congelar enlaces en tiempo de ejecución cuando se crea un bloque, y luego acceder a él más tarde cuando se invoca el bloque.

El sombreado aparece cuando considera que en un bloque solo le interesan los nombres relevantes que se definen en el entorno, por ejemplo, en la printffunción que desea utilizar. Si por casualidad usted quiere utilizar un nombre local ( getc, putc, scanf, ...) que ya ha sido utilizado en el entorno, sencilla desea ignorar (sombra) el nombre global. Por lo tanto, cuando piensa localmente, no desea considerar todo el contexto (posiblemente muy grande).

En la otra dirección, al pensar globalmente, desea ignorar los detalles internos de los contextos locales (encapsulación). Por lo tanto, debe sombrearse, de lo contrario, agregar un nombre global podría romper todos los bloques locales que ya usaban ese nombre.

En pocas palabras, si desea que un bloque de código haga referencia a enlaces definidos externamente, necesita sombreado para proteger los nombres locales de los globales.

Giorgio
fuente