匿名方法的范围

11

匿名方法的一个好处是可以使用在调用上下文中的局部变量。那么为什么这不能适用于输出参数和函数返回值呢?

function ReturnTwoStrings (out Str1 : String) : String;
begin
  ExecuteProcedure (procedure
                    begin
                      Str1 := 'First String';
                      Result := 'Second String';
                    end);
end;

虽然这只是一个非常人为的例子,但有些情况下会很有用。当我尝试编译时,编译器抱怨他“无法捕获符号”。同时,有一次我尝试这样做时出现了内部错误。

编辑 我刚刚意识到它可以用于普通参数:

... (List : TList)

这不跟其他情况一样存在问题吗?谁能保证在执行匿名方法的时候引用仍指向一个存在的对象呢?


使用指针代替引用参数。 - MajidTaheri
4个回答

21

由于这个操作的安全性无法进行静态验证,因此无法捕获变量和输出参数以及Result变量。当Result变量是托管类型时,例如字符串或接口,实际上调用者分配存储并将对此存储的引用作为隐式参数传递;换句话说,根据其类型,Result变量就像是一个输出参数。

正如Jon所提到的那样,由于匿名方法创建的闭包可能会超出创建它的方法激活的范围,并且同样可以超出调用创建它的方法的方法的激活范围。因此,任何被捕获的var、out参数或Result变量都可能成为孤立的,未来在闭包内对它们的任何写操作都会破坏堆栈。

当然,Delphi不运行在托管环境中,因此它没有与C#等语言相同的安全限制。该语言允许您做想做的事情。但是,在发生错误的情况下,会导致难以诊断的错误。这种不良行为将表现为局部变量在没有明显原因的情况下更改值;如果从另一个线程调用方法引用,则情况将更加严重。

这将相当难以调试。即使是硬件内存断点也是相对较差的工具,因为堆栈经常被修改。需要在命中另一个断点(例如在方法入口处)时有条件地打开硬件内存断点。Delphi调试器可以做到这一点,但我猜大多数人都不知道这种技巧。

更新:关于您问题的补充,传递实例引用的语义在包含闭包(并捕获参数)和不包含闭包的方法之间略有不同。任何一种方法都可能保留对按值传递的参数的引用;未捕获参数的方法可能只是将引用添加到列表中或将其存储在私有字段中。

对于通过引用传递的参数,情况则不同,因为调用者的期望是不同的。程序员执行以下操作:

procedure GetSomeString(out s: string);
// ...
GetSomeString(s);

如果GetSomeString函数保持对传入的变量s的引用,那么我会感到非常惊讶。但另一方面:

procedure AddObject(obj: TObject);
// ...
AddObject(TObject.Create);

AddObject 保留引用并不奇怪,因为其名称暗示着它将参数添加到某个有状态的存储中。这个有状态的存储是否以闭包的形式存在是 AddObject 方法的一个实现细节。


为什么要谈论堆栈?捕获的变量并不存储在堆栈中,而是存储在实现接口的隐藏对象中。例如,var M,N:Integer; - 如果只在匿名方法中使用N,则M将进入堆栈,并且N将成为隐藏对象的字段。它不会出现在堆栈上。我有什么误解吗? - Alex
@Alexander:Barry描述了当允许捕获out和var参数以及函数结果时会发生什么情况。由于不允许,因此堆栈覆盖的情况不会发生。 - Jeroen Wiert Pluimers
3
Alexander,传递引用参数和 var 参数是通过引用传递的。这意味着捕获变量只会捕获存储位置的引用,而不是位置本身。编译器无法捕获 var 或 out 参数后面的位置,因为变量捕获是通过将存储从堆栈移动到堆上实现的,这需要内部重写声明该位置的方法。由于任何代码都可以调用具有 var 或 out 参数的方法,包括其他语言,因此重新编写该方法已经太迟了。必须在编译时进行。 - Barry Kelly

6
问题在于您的Str1变量不是由ReturnTwoStrings所拥有的,因此您的匿名方法无法捕获它。
不能捕获它的原因是编译器不知道最终的所有者(在调用ReturnTwoStrings的调用堆栈中的某个位置),因此它无法确定从哪里捕获它。
编辑:(在Smasher的评论后添加)
匿名方法的核心是捕获变量(而不是它们的值)。
Allen Bauer(CodeGear)在他的博客中解释了更多关于变量捕获的内容about variable capturing in his blog
还有一个关于绕过您的问题的C# question about circumventing your problem

4
函数返回后,输出参数和返回值不再相关 - 如果您捕获并稍后执行匿名方法,您会如何期望它的行为?(特别是,如果您使用匿名方法创建委托但从未执行它,则在函数返回时不会设置输出参数和返回值。)输出参数尤其困难 - 输出参数别名的变量可能甚至在以后调用委托时都不存在。例如,假设您能够捕获输出参数并返回匿名方法,但输出参数是调用函数中的局部变量,并且它在堆栈上。如果调用方法存储了委托(或返回它)后返回,那么当最终调用委托时会发生什么情况?当设置输出参数的值时,它将写入哪里?

关于您的第一个观点:对于我使用的每个局部变量都是如此,不是吗?至于第二个观点:如果我仍然希望匿名方法生成函数结果怎么办?我可以轻松地使用局部变量来模拟这种情况,在匿名方法中使用该局部变量,然后在之后将其分配给Result。 - jpfollenius
只是为了让我的观点更清晰:如果我使用匿名方法作为委托,那么这将导致相同的问题,不是吗? - jpfollenius
1
不,对于方法内部的局部变量来说这是不正确的。它们(假设Delphi和C#类似)实际上会被放在堆上进行捕获 - 对局部变量的任何引用都会通过一个包含该范围内所有局部变量的“容器”进行访问。编译器能够为方法内部的局部变量完成这一操作,因为它知道哪些变量会被捕获 - 但它无法对调用该方法的代码中的局部变量进行此操作。 - Jon Skeet

1

我将这个回答单独列出来,因为你的编辑使得问题变得非常不同。

稍后我可能会扩展这个答案,因为我现在有点赶着去见客户。

你的编辑表明你需要重新考虑值类型、引用类型以及 var、out、const 和没有参数标记的影响。

首先让我们来看看值类型的事情。

值类型的值存储在堆栈上,并具有复制赋值行为。(稍后我会尝试包含一个例子)。

当你没有参数标记时,传递给方法(过程或函数)的实际值将被复制到该方法内部参数的本地值中。因此,该方法不是对传递给它的值进行操作,而是对其副本进行操作。

当你使用 out、var 或 const 时,就不会发生复制:方法将引用传递的实际值。对于 var,它将允许你更改该实际值,对于 const,它将不允许更改。对于 out,你将无法读取实际值,但仍然可以写入实际值。

引用类型的值存储在堆上,因此对于它们来说,是否有 out、var、const 或没有参数标记几乎没有关系:当你改变某些东西时,你就改变了堆上的值。

对于引用类型,如果没有参数标记,仍然会得到一个副本,但这是指向堆上值的引用的副本。

这就是匿名方法变得复杂的地方:它们进行变量捕获。 (Barry可能可以更好地解释这一点,但我会尝试解释一下) 在您编辑的情况下,匿名方法将捕获List的本地副本。匿名方法将在该本地副本上工作,从编译器的角度来看,一切都很好。

然而,您编辑的关键是“它适用于普通参数”和“谁保证引用在匿名方法执行时仍然指向一个活动对象”。

无论是否使用匿名方法,这始终是引用参数的问题。

例如:

procedure TMyClass.AddObject(Value: TObject);
begin
  FValue := Value;
end;

procedure TMyClass.DoSomething();
begin
  ShowMessage(FValue.ToString());
end;

谁能保证当有人调用DoSomething时,FValue指向的实例仍然存在?答案是你必须自己保证这一点,方法是在FValue实例死亡时不调用DoSomething。同样的道理也适用于你的编辑:当基础实例已经死亡时,你不应该调用匿名方法。

这是其中一个使用引用计数或垃圾回收解决方案可以使生活更轻松的领域:在那里,实例将被保持活动状态,直到对它的最后一个引用消失(这可能会导致实例的寿命比您最初预期的要长!)。

因此,通过你的编辑,你的问题实际上从匿名方法转变为使用引用类型参数和生命周期管理的影响。

希望我的答案能帮助你在这个领域前进。

--jeroen


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接