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

31

Beaucoup des plus languges de programmation populaires (tels que C ++, Java, Python , etc.) ont le concept de cacher / observation des variables ou des fonctions. 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 langues.

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).

Simon
la source
C'est pourquoi tous mes noms de domaine commencent par F.
Pieter B
7
Je pense qu'Eric Lippert avait un bel article à ce sujet. Oh, attendez, le voici: blogs.msdn.com/b/ericlippert/archive/2008/05/21/…
Lescai Ionel
1
Veuillez clarifier votre question. Vous posez des questions sur les informations qui se cachent en général, ou sur le cas spécifique décrit dans l'article de Lippert où une classe dérivée masque les fonctions de la classe de base?
Aaron Kurtzhals
Remarque importante: la plupart des bogues provoqués par le masquage / l'observation impliquent une mutation (définir la mauvaise variable et se demander pourquoi le changement "ne se produit jamais" par exemple). Lorsque vous travaillez principalement avec des références immuables, masquer / masquer provoque beaucoup moins de problèmes et est beaucoup moins susceptible de provoquer des bogues.
Jack

Réponses:

26

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 cacher et shadowing, et vous essayez de « protéger » certaines variables globales, vous créez une situation où le compilateur dit le 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.

John R. Strohm
la source
19
Un autre inconvénient de l'interdiction de l'observation est que l'ajout d'une variable globale peut casser le code car la variable avait déjà été utilisée dans un bloc local.
Giorgio
19
"Si vous interdisez le masquage et l'occultation, ce que vous avez est un langage dans lequel toutes les variables sont globales." - pas nécessairement: vous pouvez avoir des variables de portée sans ombrage, et vous l'avez expliqué.
Thiago Silva
@ThiagoSilva: Et puis votre langue doit avoir un moyen de dire au compilateur que ce module EST autorisé à accéder à la variable "frammis" de ce module. Autorisez-vous quelqu'un à cacher / masquer un objet dont il ne sait même pas qu'il existe, ou lui en parlez-vous pour lui dire pourquoi il n'est pas autorisé à utiliser ce nom?
John R. Strohm
9
@Phil, excusez-moi d'être en désaccord avec vous, mais le PO a posé des questions sur "masquer / masquer des variables ou des fonctions", et les mots "parent", "enfant", "classe" et "membre" n'apparaissent nulle part dans sa question. Cela semblerait en faire une question générale sur la portée du nom.
John R. Strohm
3
@dylnmc, je ne m'attendais pas à vivre assez longtemps pour rencontrer un whippersnapper assez jeune pour ne pas avoir une référence "2001: A Space Odyssey" évidente.
John R. Strohm, du
15

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

L'utilisation d'identificateurs 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 de types identiques / similaires (ce que vous feriez si le masquage de 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 de mettre en œuvre le masquage / l'observation que de le désactiver. 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.

Telastyn
la source
3
En fait, non, il n'est PAS plus facile de mettre en œuvre le masquage et l'observation. Il est en fait plus facile d'implémenter "toutes les variables sont globales". Vous n'avez besoin que d'un seul espace de noms, et vous exportez TOUJOURS le nom, au lieu d'avoir plusieurs espaces de noms et de devoir décider pour chaque nom de l'exporter.
John R. Strohm
5
@ JohnR.Strohm - Bien sûr, mais dès que vous avez une sorte de portée (lire: classes), le fait de masquer les portées inférieures est disponible gratuitement.
Telastyn
La portée et les classes sont différentes. À l'exception de BASIC, chaque langage dans lequel j'ai programmé a une portée, mais tous n'ont pas de concept de classes ou d'objets.
Michael Shaw
@michaelshaw - bien sûr, j'aurais dû être plus clair.
Telastyn
7

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, quand 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 du fait 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 devrez 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 OO ne représentent guère plus que cela, conceptuellement, c'est faux; si les consommateurs de besoin Bar 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, sauf si vous ne le vouliez pas spécifiquement. Il a également encore ne vous donne pas le comportement exact de sa cachette; 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.

KeithS
la source
+1 pour répondre à la question réelle. L' interface IEnumerableet IEnumerable<T>, décrite dans le blog d' Eric Libbert sur le sujet, est un bon exemple d'utilisation réelle de la dissimulation de membres .
Phil
Le dépassement ne se cache pas . Je ne suis pas d'accord avec @Phil que cela répond à la question.
Jan Hudec
Mon point était que le dépassement remplacerait la dissimulation lorsque la dissimulation n'est pas une option. Je suis d'accord, il ne se cache pas, et je le dis dans le tout premier paragraphe. Aucune des solutions de contournement à mon scénario de réalité alternative de ne pas se cacher en C # ne se cache; c'est le but.
KeithS
Je n'aime pas vos utilisations de l'observation / masquage. Les principaux bons usages que je vois sont (1) le contournement de la situation où une nouvelle version d'une classe de base comprend un membre qui entre en conflit avec le code de consommation conçu autour d'une ancienne version [moche mais nécessaire]; (2) truquer des choses comme la covariance de type retour; (3) traiter des cas où une méthode de classe de base est appelable sur un sous-type particulier mais pas utile . Le LSP requiert le premier, mais pas le second si le contrat de classe de base spécifie que certaines méthodes peuvent ne pas être utiles dans certaines conditions.
supercat
2

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 .NET IEnumerableet les IEnumerable<T>interfaces sont de bons exemples de cas où le masquage de membres est utile.

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

public interface IEnumerable
{
    IEnumerator GetEnumerator();
}

Cette interface est ce qui nous a permis de foreachsurvoler une collection d'objets, mais nous avons dû transtyper tous ces objets pour 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 bête, parce que GetEnumerator()et GetEnumeratorGeneric()dans les deux cas faire à peu près exactement la même chose , mais ils ont légèrement différentes valeurs de retour. En fait, ils sont si similaires que vous souhaitez presque toujours utiliser la forme générique par défaut GetEnumerator, sauf si vous travaillez avec du code hérité qui a été écrit avant l'introduction des génériques dans .NET.

Parfois , cacher membre ne permet plus de place pour le code méchant et des bugs 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?

Phil
la source
Alors que formellement le IEnumerable<T>.GetEnumerator()masque le IEnumerable.GetEnumerator(), c'est uniquement parce que C # n'a pas de types de retour covariants lors de la substitution. Logiquement, il s'agit d'une dérogation, entièrement conforme au LSP. Cacher, c'est quand vous avez une variable locale mapdans la fonction dans un fichier qui le fait using namespace std(en C ++).
Jan Hudec
2

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 référence à n'importe quelle 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 un 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é un article intéressant de Bertrand Meyer qui traite 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 n'autorise pas les conflits de noms comme celui-ci et force 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.

Michael Shaw
la source
2

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 est appelé liaison .

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

  1. Noms définis localement, par exemple des variables locales, qui ne sont connus qu'à l'intérieur du bloc.
  2. Arguments liés à des valeurs lors de l'appel du bloc 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_inta un nom local (variable locale) det 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 cela printfpourrait é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 l' injection de dépendances , 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.

Prenons par exemple ce code 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 createMultiplierest la fermeture (m: Int) => m * n, qui contient l'argument met le nom externe n. Le nom nest résolu en regardant le contexte dans lequel la fermeture est définie: le nom est lié à l'argument nde fonction createMultiplier. Notez que cette liaison est créée lorsque la fermeture est créée, c'est-à-dire lorsqu'elle createMultiplierest invoquée. Le nom nest donc 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 commeprintf , qui est résolue par l'éditeur de liens lorsque l'exécutable du programme est construit.

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 geler 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 n'êtes intéressé que par les noms pertinents définis dans l'environnement, par exemple dans la printffonction 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 d'ignorer (ombre) le nom global. Donc, quand vous pensez localement, vous ne voulez pas considérer le contexte entier (peut-être très grand).

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.

Giorgio
la source