为什么编程语言允许对变量和函数进行阴影/隐藏?


31

许多最流行的编程语言(例如C ++,Java,Python等)都具有隐藏隐藏变量或函数的概念。当我遇到隐藏或阴影时,它们是导致难以发现错误的原因,而且我从未见过需要使用这些语言功能的情况。

在我看来,禁止隐藏和遮盖似乎更好。

有人知道这些概念的好用法吗?

更新:
我不是指封装类成员(私有/受保护的成员)。


这就是为什么我所有的字段名与F.开始
彼得·乙

7
我认为埃里克·利珀特(Eric Lippert)在这方面有一篇不错的文章。哦,等等,这就是:blogs.msdn.com/b/ericlippert/archive/2008/05/21/...
Lescai约内尔

1
请澄清您的问题。您是在询问有关隐藏信息的一般信息,还是有关Lippert文章中描述的派生类隐藏基类函数的特定案例的信息?
亚伦·库尔扎尔斯

重要说明:由隐藏/阴影引起的许多错误都涉及突变(例如,设置错误的变量并想知道为什么更改“从未发生”)。当主要使用不可变的引用时,隐藏/遮蔽所引起的问题要少得多,并且引起错误的可能性也要小得多。
杰克

Answers:


26

如果您不允许隐藏和阴影,那么您所拥有的就是所有变量都是全局变量的语言。

这显然比允许可能隐藏全局变量或函数的局部变量或函数差。

如果不允许隐藏和阴影,并且试图“保护”某些全局变量,则会导致编译器告诉程序员“对不起,戴夫,但您不能使用该名称,该名称已在使用中” 。” 使用COBOL的经验表明,在这种情况下,程序员几乎立即诉诸亵渎。

基本问题不是隐藏/阴影,而是全局变量。


19
禁止阴影的另一个缺点是,添加全局变量可能会破坏代码,因为该变量已在本地块中使用。
乔治

19
“如果您不允许隐藏和遮挡,那么您所拥有的语言就是所有变量都是全局的。” -不一定:您可以在没有阴影的情况下使用范围变量,并对此进行了解释。
Thiago Silva

@ThiagoSilva:然后你的语言必须有一种方式来告诉编译器,这个模块IS允许访问该模块的“frammis”变量。您是否允许某人隐藏/遮蔽他甚至不知道存在的物体,还是告诉他有关此事的原因,以告诉他为什么不允许他使用该名称?
John R. Strohm 2013年

9
@Phil,请原谅我不同意您的意见,但是OP询问了“隐藏/隐藏变量或函数”,并且“父母”,“孩子”,“类”和“成员”一词在他的问题中无处可寻。这似乎使它成为有关名称范围的一般性问题。
John R. Strohm 2013年

3
@dylnmc,我没想到他会活得足够长,而遇到一个年轻的young子鲷,却没有得到明显的“ 2001:太空漫游”参考。
John R. Strohm 2015年

15

有人知道这些概念的好用法吗?

使用准确的描述性标识符始终是一个好习惯。

我可以说变量隐藏不会引起很多错误,因为具有两个相同/相似类型的非常相似命名的变量(如果不允许变量隐藏,您会怎么做)可能会导致同样多的错误和/或严重的错误。我不知道该论点是否正确,但这至少是有争议的。

使用某种匈牙利符号来区分字段和局部变量可以解决此问题,但是对维护(和程序员的理智)有其自身的影响。

并且(也许很可能是一开始就知道这个概念的原因)对于语言而言,实现隐藏/阴影比禁止隐藏/阴影要容易得多。易于实现意味着编译器更不会出现错误。更容易的实现意味着编译器花费更少的时间来编写,从而导致更早和更广泛的平台采用。


3
实际上,不,实现隐藏和阴影并不容易。实际上,实现“所有变量都是全局的”更为容易。您只需要一个名称空间,并且总是导出名称,而不是具有多个名称空间,并且必须为每个名称决定是否导出它。
John R. Strohm 2013年

5
@ JohnR.Strohm -当然,但只要你有任何类型的作用域(阅读:类),则具有范围隐藏较低范围内有免费的。
Telastyn

作用域和类是不同的东西。除了BASIC之外,我编程的每种语言都有作用域,但并非所有语言都具有类或对象的概念。
迈克尔·肖

@michaelshaw-当然,我应该更加清楚。
Telastyn

7

为了确保我们在同一页面上,方法“隐藏”是当派生类定义的成员与基类中的成员具有相同的名称时(如果是方法/属性,则不标记为虚拟/可重写) ),并在“派生上下文”中从派生类的实例调用时,使用派生成员,而如果在其基类的上下文中由同一实例调用,则使用基类成员。这与成员抽象/重写(在其中基类成员希望派生类定义一个替换对象)以及在“范围/可见性”修饰符中将其“隐藏”成员到所需范围之外的使用者不同。

关于为什么允许它的简短答案是,如果不这样做,将迫使开发人员违反面向对象设计的几个关键原则。

这是更长的答案;首先,在C#不允许成员隐藏的备用Universe中考虑以下类结构:

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

我们要取消注释Bar中的成员,并这样做,允许Bar提供不同的MyFooString。但是,我们不能这样做,因为它违反了成员隐藏的替代现实禁止。这个特定的示例非常容易出现错误,并且是为什么您可能想要禁止它的主要示例。例如,如果执行以下操作,您将获得什么控制台输出?

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

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

实际上,我不确定在最后一行上是“ Foo”还是“ Bar”。即使所有三个变量都引用状态完全相同的实例,您也肯定会在第一行得到“ Foo”,在第二行得到“ Bar”。

因此,在我们的替代宇宙中,语言的设计者通过防止属性隐藏来阻止这种明显不好的代码。现在,作为编码员,您确实需要做到这一点。您如何解决限制?好吧,一种方法是用不同的方式命名Bar的属性:

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

完全合法,但这不是我们想要的行为。当我们希望Bar实例产生“ Bar”时,它的实例将始终为其属性MyFooString产生“ Foo”。我们不仅必须知道我们的IFoo是一个Bar,而且还必须知道使用其他访问器。

我们也很可能会忘记父子关系并直接实现接口:

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

对于这个简单的示例,只要您只关心Foo和Bar都是IFoo ,这是一个完美的答案。几个示例的用法代码将无法编译,因为Bar不是Foo,因此也不能这样分配。但是,如果Foo有Bar需要一些有用的方法“ FooMethod”,那么现在您将无法继承该方法。您要么必须在Bar中克隆其代码,要么要发挥创意:

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

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

这是一个很明显的hack,虽然OO语言规范的某些实现只不过于此,但从概念上讲这是错误的。如果酒吧需要消费者揭露Foo的功能,酒吧应该一个富,不是一个Foo。

显然,如果我们控制Foo,则可以将其虚拟化,然后将其覆盖。这是我们当前Universe中的概念上最佳实践,当期望某个成员被覆盖时,它将保留在不允许隐藏的任何替代Universe中:

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

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

这样做的问题在于,虚拟成员访问的执行成本相对较高,因此通常只需要在需要时执行此操作。然而,缺乏隐藏迫使您对那些无法控制您的源代码的其他编码器可能想要重新实现的成员感到悲观。对于任何非密封类,“最佳实践”都是将所有内容虚拟化,除非您明确不希望这样做。它还仍然无法为您提供隐藏的确切行为。如果实例是Bar,则字符串将始终为“ Bar”。有时,根据工作所在的继承级别,利用隐藏状态数据的层确实很有用。

总而言之,允许成员隐藏是这些弊端中较小的一个。如果没有它,通常会导致对面向对象的原则的残酷暴行。


+1用于解决实际问题。Eric Libbert 关于该主题的博客文章中介绍了IEnumerableand IEnumerable<T>接口,这是在现实世界中使用成员隐藏的一个很好的例子。
Phil

覆盖不是隐藏。我不同意@Phil所说的这个问题。
Jan Hudec

我的观点是,当无法选择隐藏时,覆盖将替代隐藏。我同意,它没有隐藏,在第一段中我也说了很多。我在C#中不隐藏的备用现实方案的所有变通办法都没有隐藏。这才是重点。
KeithS 2013年

我不喜欢您使用阴影/隐藏。我看到的主要好处是(1)围绕一种情况,即新版本的基类包含一个成员,该成员与围绕较旧版本设计的消费者代码冲突(难看但必要);(2)伪造诸如收益型协方差之类的东西;(3)处理在特定子类型上调用基类方法但无用的情况。LSP需要前者,但如果基类合同规定某些方法在某些情况下可能没有用,则不需要后者。
2014年

2

坦率地说,C#编译器团队的主要开发人员Eric Lippert 对此进行了很好的解释(感谢Lescai Ionel提供的链接)。.NET IEnumerableIEnumerable<T>接口是成员隐藏何时有用的好例子。

在.NET的早期,我们没有泛型。所以IEnumerable界面看起来像这样:

public interface IEnumerable
{
    IEnumerator GetEnumerator();
}

这个接口使我们能够foreach遍历对象的集合,但是我们必须转换所有这些对象才能正确使用它们。

然后是泛型。当获得泛型时,我们还获得了一个新接口:

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

现在,当我们遍历对象时,不必投射对象!!现在,如果不允许成员隐藏,则界面必须看起来像这样:

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

这将是一种愚蠢的,因为GetEnumerator()GetEnumeratorGeneric()在这两种情况下做几乎完全一样的东西,但他们有略微不同的返回值。实际上,它们是如此相似,以致您几乎总是希望默认使用的通用形式GetEnumerator,除非您使用的是在将通用引入.NET之前编写的旧代码。

有时成员隐藏确实为讨厌的代码和难以发现的错误留出了更多空间。但是有时它很有用,例如,当您想更改返回类型而不破坏原有代码时。那只是语言设计师必须做出的决定之一:我们是否会给合法需要此功能的开发人员带来不便,还是将其遗漏?还是我们将此功能包含在语言中,并从那些遭受误用的受害者中冒出来?


虽然正式IEnumerable<T>.GetEnumerator()隐藏了IEnumerable.GetEnumerator(),但这仅是因为C#在覆盖时没有协变返回类型。从逻辑上讲,它是一个替代,完全符合LSP。隐藏是指map在文件中的函数中具有局部变量的情况using namespace std(在C ++中)。
Jan Hudec

2

您可以通过两种方式来理解您的问题:要么是一般地询问变量/函数范围,要么是询问有关继承层次结构中范围的更具体的问题。您没有专门提到继承,但是您确实提到了很难发现的bug,在继承上下文中,听起来更像是作用域而不是简单作用域,因此我将回答两个问题。

通常,范围是一个好主意,因为它使我们能够将注意力集中在程序的一个特定(希望很小)的部分上。因为它使本地名称始终获胜,所以如果您仅读取程序中位于给定范围内的部分,那么您将确切知道本地定义了哪些部分以及在其他地方定义了什么。该名称要么是指本地的东西,在这种情况下,定义它的代码就在您的前面,或者是对本地范围之外的东西的引用。如果没有任何非本地引用可以从我们下面改变(特别是全局变量,可以从任何地方更改),那么我们可以评估该程序在本地范围内的一部分是否正确而无需引用到程序其余部分的任何部分

它可能偶尔会导致一些错误,但是它通过防止大量其他可能的错误来弥补。除了使用与库函数同名的局部定义之外(不要那样做),我看不到一种简单的方法来引入具有局部作用域的错误,但是局部作用域使同一程序的许多部分得以使用我作为循环的索引计数器,彼此之间不会互相干扰,并让弗雷德在大厅里编写了一个函数,该函数使用名为str的字符串,不会破坏具有相同名称的字符串。

我找到 Bertrand Meyer的一篇有趣的文章该文章讨论了继承上下文中的重载。他提出了一个有趣的区别,在他所说的句法重载(意味着有两个同名事物)和语义重载(意味着同一个抽象思想有两个不同实现)之间。语义重载就可以了,因为您打算在子类中以不同的方式实现它。语法重载可能是导致错误的偶然名称冲突。

在有意的继承情况和有缺陷的继承情况下重载之间的区别是语义(含义),因此编译器无法知道您所做的是对还是错。在普通情况下,正确的答案始终是本地事物,因此编译器可以找出正确的事物。

Bertrand Meyer的建议是使用像Eiffel这样的语言,该语言不允许这样的名称冲突,并迫使程序员重命名一个或两个,从而完全避免了问题。我的建议是避免完全使用继承,也完全避免问题。如果您不能或不想做任何一件事情,仍然可以采取一些措施来减少继承问题的可能性:遵循LSP(Liskov替代原理),更喜欢使用组合而不是继承,保留您的继承层次结构较浅,而使继承层次结构中的类较小。此外,某些语言即使不会发出错误,也可能会发出警告,就像Eiffel这样的语言一样。


2

这是我的两分钱。

程序可以构造为块(功能,过程),这些块是程序逻辑的独立单元。每个块都可以使用名称/标识符来引用“事物”(变量,函数,过程)。从名称到事物的这种映射称为绑定

块使用的名称分为三类:

  1. 局部定义的名称,例如局部变量,仅在块内部已知。
  2. 调用该块时绑定到值的参数,调用者可以使用这些参数指定该块的输入/输出参数。
  3. 在包含该块的环境中定义的外部名称/绑定,并且在该块的范围内。

考虑下面的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);
}

该函数print_double_int具有一个本地名称(局部变量)d和一个参数n,并使用外部全局名称printf,该名称在范围内但未在本地定义。

注意,printf这也可以作为参数传递:

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

通常,参数用于指定函数(过程,块)的输入/输出参数,而全局名称用于表示诸如“存在于环境中”的库函数之类的事物,因此更方便地提及它们仅在需要它们时。使用参数而不是全局名称是依赖项注入的主要思想,当必须显式依赖项而不是通过查看上下文来解决依赖项时,将使用此方法。

可以在闭包中找到外部定义名称的另一种类似用法。在这种情况下,可以在块的词法上下文中使用在该块的词法上下文中定义的名称,并且只要该块引用它,绑定到该名称的值(通常)将继续存在。

以下面的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))
  }
}

函数的返回值createMultiplier是闭包(m: Int) => m * n,闭包包含参数m和外部名称nn通过查看定义闭包的上下文来解析名称:名称绑定到nfunction 的参数createMultiplier。请注意,此绑定是在创建闭包时createMultiplier(即在调用时)创建的。因此,该名称n绑定到该函数的特定调用的参数的实际值。与此形成对比的是库函数(如)的情况,该库函数printf在构建程序的可执行文件时由链接程序解析。

总之,在本地代码块中引用外部名称可能很有用,这样您就可以

  • 不需要/不想将外部定义的名称显式传递为参数,并且
  • 您可以在创建块时在运行时冻结绑定,然后在调用该块时稍后访问它。

当您考虑在一个块中,您仅对环境中定义的相关名称(例如,printf要使用的功能)感兴趣时,就会出现阴影。如果碰巧你想使用一个本地名称(getcputcscanf,...),其已在该环境中使用,你简单的想忽略(阴影)全局名称。因此,在本地思考时,您不想考虑整个(可能非常大)上下文。

另一方面,在全局考虑时,您想忽略本地上下文的内部细节(封装)。因此,您需要阴影,否则添加全局名称可能会破坏已经使用该名称的每个本地块。

底线是,如果要让代码块引用外部定义的绑定,则需要进行阴影处理以保护本地名称免受全局名称的影响。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.