web-dev-qa-db-fra.com

Pourquoi les langages de programmation permettent-ils l'observation / masquage des variables et des fonctions?

La plupart des langages de programmation les plus populaires (tels que C++, Java, Python etc.) ont le concept de masquage/ombrage de variables Lorsque j'ai rencontré des problèmes de masquage ou d'observation, ils ont été la cause de bogues difficiles à trouver et je n'ai jamais vu de cas où j'ai trouvé nécessaire d'utiliser ces fonctionnalités des langages.

Il me semble qu'il serait préférable de ne pas cacher ni observer.

Quelqu'un connaît-il une bonne utilisation de ces concepts?

Mise à jour:
Je ne fais pas référence à l'encapsulation des membres de la classe (membres privés/protégés).

31
Simon

Si vous interdisez le masquage et l'observation, vous disposez d'un langage dans lequel toutes les variables sont globales.

C'est clairement pire que d'autoriser des variables ou des fonctions locales qui peuvent masquer des variables ou des fonctions globales.

Si vous interdisez le masquage et l'observation, ET vous essayez de "protéger" certaines variables globales, vous créez une situation où le compilateur dit au programmeur "Je suis désolé , Dave, mais vous ne pouvez pas utiliser ce nom, il est déjà utilisé. " L'expérience avec COBOL montre que les programmeurs recourent presque immédiatement au blasphème dans cette situation.

Le problème fondamental n'est pas le masquage/l'observation, mais les variables globales.

27
John R. Strohm

Quelqu'un connaît-il une bonne utilisation de ces concepts?

L'utilisation d'identifiants précis et descriptifs est toujours une bonne utilisation.

Je pourrais faire valoir que le masquage de variables ne cause pas beaucoup de bogues, car avoir deux variables nommées de manière très similaire du même type/similaire (ce que vous feriez si le masquage des variables était interdit) est susceptible de provoquer autant de bogues et/ou bugs graves. Je ne sais pas si cet argument est correct, mais il est au moins plausiblement discutable.

L'utilisation d'une sorte de notation hongroise pour différencier les champs des variables locales contourne ce problème, mais a son propre impact sur la maintenance (et la santé mentale du programmeur).

Et (peut-être probablement la raison pour laquelle le concept est connu en premier lieu), il est beaucoup plus facile pour les langues d'implémenter le masquage/l'observation que de le refuser. Une implémentation plus facile signifie que les compilateurs sont moins susceptibles d'avoir des bogues. Une implémentation plus facile signifie que les compilateurs prennent moins de temps à écrire, ce qui entraîne une adoption plus rapide et plus large de la plate-forme.

15
Telastyn

Juste pour nous assurer que nous sommes sur la même page, la méthode de "masquage" est lorsqu'une classe dérivée définit un membre du même nom que celui de la classe de base (qui, s'il s'agit d'une méthode/propriété, n'est pas marqué virtuel/remplaçable ), et lorsqu'il est appelé à partir d'une instance de la classe dérivée dans le "contexte dérivé", le membre dérivé est utilisé, tandis que s'il est appelé par la même instance dans le contexte de sa classe de base, le membre de la classe de base est utilisé. Ceci est différent de l'abstraction/remplacement de membre où le membre de la classe de base attend de la classe dérivée qu'elle définisse un remplacement, et des modificateurs de portée/visibilité qui "cachent" un membre aux consommateurs en dehors de la portée souhaitée.

La réponse courte à la raison pour laquelle cela est autorisé est que ne pas le faire forcerait les développeurs à violer plusieurs principes clés de la conception orientée objet.

Voici la réponse la plus longue; tout d'abord, considérez la structure de classe suivante dans un univers alternatif où C # n'autorise pas le masquage des membres:

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

Nous voulons décommenter le membre de Bar et, ce faisant, permettre à Bar de fournir une MyFooString différente. Cependant, nous ne pouvons pas le faire car cela violerait l'interdiction de la réalité alternative de cacher des membres. Cet exemple particulier serait plein de bogues et est un excellent exemple de pourquoi vous pourriez vouloir l'interdire; par exemple, quelle sortie de console obtiendriez-vous si vous faisiez ce qui suit?

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

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

Du haut de ma tête, je ne sais pas vraiment si vous obtiendrez "Foo" ou "Bar" sur cette dernière ligne. Vous obtiendrez certainement "Foo" pour la première ligne et "Bar" pour la seconde, même si les trois variables font référence exactement à la même instance avec exactement le même état.

Ainsi, les concepteurs du langage, dans notre univers alternatif, découragent ce code manifestement mauvais en empêchant le masquage des propriétés. Maintenant, en tant que codeur, vous avez vraiment besoin de faire exactement cela. Comment contournez-vous la limitation? Eh bien, une façon consiste à nommer la propriété de Bar différemment:

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

Parfaitement légal, mais ce n'est pas le comportement que nous voulons. Une instance de Bar produira toujours "Foo" pour la propriété MyFooString, lorsque nous voulions qu'elle produise "Bar". Non seulement nous devons savoir que notre IFoo est spécifiquement un Bar, nous devons également savoir utiliser les différents accesseurs.

Nous pourrions également, de manière tout à fait plausible, oublier la relation parent-enfant et implémenter directement l'interface:

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

Pour cet exemple simple, c'est une réponse parfaite, tant que vous vous souciez seulement que Foo et Bar sont tous deux IFoos. Le code d'utilisation de quelques exemples ne pourrait pas être compilé car un Bar n'est pas un Foo et ne peut pas être attribué en tant que tel. Cependant, si Foo avait une méthode utile "FooMethod" dont Bar avait besoin, vous ne pouvez plus hériter de cette méthode; vous devez soit cloner son code dans Bar, soit faire preuve de créativité:

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

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

Il s'agit d'un hack évident, et bien que certaines implémentations des spécifications du langage O-O ne représentent guère plus que cela, conceptuellement, c'est faux; si les consommateurs de Bar ont besoin d'exposer les fonctionnalités de Foo, Bar devrait être un Foo, pas avoir un Foo.

Évidemment, si nous contrôlions Foo, nous pouvons le rendre virtuel, puis le remplacer. Il s'agit de la meilleure pratique conceptuelle dans notre univers actuel lorsqu'un membre est censé être remplacé, et se maintiendrait dans tout autre univers qui ne permettait pas de se cacher:

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

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

Le problème avec cela est que l'accès aux membres virtuels est, sous le capot, relativement plus coûteux à effectuer, et donc vous ne voulez généralement le faire que lorsque vous en avez besoin. Le manque de masquage, cependant, vous oblige à être pessimiste quant aux membres qu'un autre codeur qui ne contrôle pas votre code source pourrait vouloir réimplémenter; la "meilleure pratique" pour toute classe non scellée serait de tout rendre virtuel à moins que vous ne le vouliez pas spécifiquement. Cela aussi encore ne vous donne pas le comportement exact de se cacher; la chaîne sera toujours "Bar" si l'instance est une barre. Parfois, il est vraiment utile de tirer parti des couches de données d'état cachées, en fonction du niveau d'héritage auquel vous travaillez.

En résumé, permettre aux membres de se cacher est le moindre de ces maux. Ne pas l'avoir entraînerait généralement de pires atrocités commises contre des principes orientés objet que de le permettre.

7
KeithS

Honnêtement, Eric Lippert, le développeur principal de l'équipe du compilateur C #, l'explique assez bien (merci Lescai Ionel pour le lien). Les interfaces IEnumerable et IEnumerable<T> De .NET sont de bons exemples de masquage de membres utile.

Au début de .NET, nous n'avions pas de génériques. L'interface IEnumerable ressemblait donc à ceci:

public interface IEnumerable
{
    IEnumerator GetEnumerator();
}

Cette interface est ce qui nous a permis de foreach sur une collection d'objets, mais nous avons dû transtyper tous ces objets afin de les utiliser correctement.

Viennent ensuite les génériques. Lorsque nous avons obtenu des génériques, nous avons également obtenu une nouvelle interface:

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

Maintenant, nous n'avons plus à lancer d'objets pendant que nous les parcourons! Woot! Maintenant, si le masquage des membres n'était pas autorisé, l'interface devrait ressembler à ceci:

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

Ce serait un peu idiot, car GetEnumerator() et GetEnumeratorGeneric() dans les deux cas font à peu près exactement la même chose , mais ils ont des valeurs de retour légèrement différentes. Ils sont si similaires, en fait, que vous (à peu près toujours voulez par défaut la forme générique de GetEnumerator, à moins que vous ne soyez travailler avec du code hérité qui a été écrit avant l'introduction des génériques dans .NET.

Parfois, le masquage des membres permet de laisser plus de place au code désagréable et aux bogues difficiles à trouver. Cependant, il est parfois utile, par exemple lorsque vous souhaitez modifier un type de retour sans casser le code hérité. Ce n'est qu'une de ces décisions que les concepteurs de langage doivent prendre: gênons-nous les développeurs qui ont légitimement besoin de cette fonctionnalité et la laissons-nous ou incluons-nous cette fonctionnalité dans le langage et attrapons-nous les flaks de ceux qui sont victimes de son utilisation abusive?

2
Phil

Votre question pourrait être lue de deux manières: soit vous posez des questions sur la portée des variables/fonctions en général, soit vous posez une question plus spécifique sur la portée dans une hiérarchie d'héritage. Vous n'avez pas mentionné spécifiquement l'héritage, mais vous avez mentionné des bogues difficiles à trouver, ce qui ressemble plus à la portée dans le contexte de l'héritage qu'à la portée ordinaire, donc je répondrai aux deux questions.

La portée en général est une bonne idée, car elle nous permet de concentrer notre attention sur une partie spécifique (espérons-le petite) du programme. Parce qu'il permet aux noms locaux de toujours gagner, si vous ne lisez que la partie du programme qui est dans une portée donnée, alors vous savez exactement quelles parties ont été définies localement et ce qui a été défini ailleurs. Soit le nom fait référence à quelque chose de local, auquel cas le code qui le définit est juste devant vous, soit c'est une référence à quelque chose en dehors de la portée locale. S'il n'y a pas de références non locales qui pourraient changer sous nous (en particulier les variables globales, qui pourraient être modifiées de n'importe où), alors nous pouvons évaluer si la partie du programme dans la portée locale est correcte ou non sans se référer à aucune partie du reste du programme .

Cela peut parfois conduire à quelques bugs, mais cela compense largement en empêchant une énorme quantité de bugs autrement possibles. Autre que de faire une définition locale avec le même nom qu'une fonction de bibliothèque (ne faites pas ça), je ne vois pas de moyen facile d'introduire des bogues avec une portée locale, mais la portée locale est ce qui permet à de nombreuses parties du même programme d'utiliser i comme compteur d'index pour une boucle sans s'encombrer et laisse Fred descendre le couloir écrire une fonction qui utilise une chaîne nommée str qui n'encombrera pas votre chaîne avec le même nom.

J'ai trouvé n article intéressant par Bertrand Meyer qui discute de la surcharge dans le contexte de l'héritage. Il évoque une distinction intéressante, entre ce qu'il appelle la surcharge syntaxique (ce qui signifie qu'il y a deux choses différentes avec le même nom) et la surcharge sémantique (ce qui signifie qu'il y a deux implémentations différentes de la même idée abstraite). La surcharge sémantique serait bien, car vous vouliez l'implémenter différemment dans la sous-classe; une surcharge syntaxique serait la collision de noms accidentelle qui a causé un bogue.

La différence entre la surcharge dans une situation d'héritage qui est prévue et qui est un bogue est la sémantique (la signification), donc le compilateur n'a aucun moyen de savoir si ce que vous avez fait est bien ou mal. Dans une situation de portée simple, la bonne réponse est toujours la chose locale, de sorte que le compilateur peut déterminer quelle est la bonne chose.

La suggestion de Bertrand Meyer serait d'utiliser un langage comme Eiffel, qui ne permet pas les conflits de noms comme celui-ci et oblige le programmeur à renommer l'un ou les deux, évitant ainsi complètement le problème. Ma suggestion serait d'éviter d'utiliser entièrement l'héritage, en évitant également complètement le problème. Si vous ne pouvez pas ou ne voulez pas faire l'une de ces choses, il y a encore des choses que vous pouvez faire pour réduire la probabilité d'avoir un problème avec l'héritage: suivez le LSP (Liskov Substitution Principle), préférez la composition à l'héritage, gardez vos hiérarchies d'héritage peu profondes et maintenez les classes dans une hiérarchie d'héritage petites. En outre, certaines langues peuvent émettre un avertissement, même si elles ne génèrent pas d'erreur, comme le ferait une langue comme Eiffel.

2
Michael Shaw

Voici mes deux cents.

Les programmes peuvent être structurés en blocs (fonctions, procédures) qui sont des unités autonomes de logique de programme. Chaque bloc peut faire référence à des "choses" (variables, fonctions, procédures) en utilisant des noms/identifiants. Ce mappage des noms aux choses s'appelle liaison.

Les noms utilisés par un bloc se répartissent en trois catégories:

  1. Noms définis localement, par ex. les variables locales, qui ne sont connues qu'à l'intérieur du bloc.
  2. Arguments liés à des valeurs lorsque le bloc est appelé et pouvant être utilisés par l'appelant pour spécifier le paramètre d'entrée/sortie du bloc.
  3. Noms/liaisons externes qui sont définis dans l'environnement dans lequel le bloc est contenu et sont à portée dans le bloc.

Considérons par exemple le programme C suivant

#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 fonction print_double_int a un nom local (variable locale) d et un argument n, et utilise le nom global externe printf, qui est dans la portée mais n'est pas défini localement.

Notez que printf peut également être passé en argument:

#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);
}

Normalement, un argument est utilisé pour spécifier les paramètres d'entrée/sortie d'une fonction (procédure, bloc), tandis que les noms globaux sont utilisés pour faire référence à des choses comme les fonctions de bibliothèque qui "existent dans l'environnement", et il est donc plus pratique de les mentionner seulement quand ils sont nécessaires. L'utilisation d'arguments au lieu de noms globaux est l'idée principale de injection de dépendance, qui est utilisée lorsque les dépendances doivent être rendues explicites au lieu d'être résolues en regardant le contexte.

Une autre utilisation similaire de noms définis en externe peut être trouvée dans les fermetures. Dans ce cas, un nom défini dans le contexte lexical d'un bloc peut être utilisé dans le bloc, et la valeur liée à ce nom continuera (typiquement) d'exister tant que le bloc s'y réfère.

Prenez par exemple ce 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))
  }
}

La valeur de retour de la fonction createMultiplier est la fermeture (m: Int) => m * n, qui contient l'argument m et le nom externe n. Le nom n est résolu en examinant le contexte dans lequel la fermeture est définie: le nom est lié à l'argument n de la fonction createMultiplier. Notez que cette liaison est créée lorsque la fermeture est créée, c'est-à-dire lorsque createMultiplier est invoqué. Ainsi, le nom n est lié à la valeur réelle d'un argument pour une invocation particulière de la fonction. Comparez cela avec le cas d'une fonction de bibliothèque comme printf, qui est résolue par l'éditeur de liens lors de la construction de l'exécutable du programme.

En résumé, il peut être utile de faire référence à des noms externes dans un bloc de code local afin que vous

  • n'ont pas besoin/ne souhaitent pas passer explicitement des noms définis en externe comme arguments, et
  • vous pouvez figer les liaisons lors de l'exécution lorsqu'un bloc est créé, puis y accéder ultérieurement lorsque le bloc est appelé.

L'observation intervient lorsque vous considérez que dans un bloc, vous êtes uniquement intéressé par les noms pertinents définis dans l'environnement, par exemple dans la fonction printf que vous souhaitez utiliser. Si par hasard vous souhaitez utiliser un nom local (getc, putc, scanf, ...) qui a déjà été utilisé dans l'environnement, vous voulez simplement ignorer (ombre) le nom global. Donc, lorsque vous réfléchissez localement, vous ne voulez pas considérer l'ensemble (peut-être très grand) du contexte.

Dans l'autre sens, en pensant globalement, vous voulez ignorer les détails internes des contextes locaux (encapsulation). Par conséquent, vous devez observer, sinon l'ajout d'un nom global pourrait casser tous les blocs locaux qui utilisaient déjà ce nom.

En bout de ligne, si vous voulez qu'un bloc de code fasse référence à des liaisons définies en externe, vous devez observer pour protéger les noms locaux des noms globaux.

2
Giorgio