为什么我无法将函数引用分配给匹配的变量?引发E2555错误。

15

我正在尝试构建一个自定义比较器,它允许将比较函数分配给内部字段。为了简化比较器的创建,我尝试添加一个类似构造函数的类函数 Construct 以初始化比较器。

现在,如果我尝试编译以下示例,则编译器会显示:

[dcc32 Fehler] ConsoleDemo1.dpr(37): E2555 符号 'Result' 无法跟踪

我有以下示例代码:

program ConsoleDemo1;

{$APPTYPE CONSOLE}
{$R *.res}

uses
  Generics.Collections, Generics.Defaults,
  System.SysUtils;

type

  TConstFunc<T1, T2, TResult> = reference to function(const Arg1: T1; const Arg2: T2): TResult;

  TDemo = class(TComparer<string>)
  private
    FVar: TConstFunc<string, string, Integer>;
    function CompareInternal(const L, R: string): Integer;
  public
    class function Construct(): TDemo;
    function Compare(const L, R: string): Integer; override;
  end;

function TDemo.Compare(const L, R: string): Integer;
begin
  Result := FVar(L, R);
end;

function TDemo.CompareInternal(const L, R: string): Integer;
begin
  Result := AnsiCompareStr(L, R);
end;

class function TDemo.Construct: TDemo;
begin
  Result := TDemo.Create();
  Result.FVar := Result.CompareInternal;
end;

end.

我猜这是由于编译器的一些魔法。如果我使用 TConstFunc<T1, T2, TResult> = function(const Arg1: T1; const Arg2: T2): TResult of object;,那么代码就可以编译并正常工作。 - ventiseis
3个回答

12

我认为这不是一个bug。关键是,你将TConstFunc定义为匿名方法类型。这些是管理的、有引用计数的非常特殊的类型,与常规对象方法非常不同。通过编译器的魔法,它们通常是赋值兼容的,但有几个重要的警告。考虑更简洁的:

program Project1;

{$APPTYPE CONSOLE}

type
  TFoo = reference to procedure;

  TDemo = class
  private
    FFoo : TFoo;
    procedure Foo;
  public
    class function Construct(): TDemo;
  end;

procedure TDemo.Foo;
begin
  WriteLn('foo');
end;

class function TDemo.Construct: TDemo;
begin
  result := TDemo.Create();
  result.FFoo := result.foo;
end;

end.

这也会产生相同的编译错误(E2555)。由于成员方法是对象过程(对象方法)类型,并且您正在将其分配给过程引用(匿名方法)类型,因此这等效于(我怀疑编译器正在扩展此操作):

class function TDemo.Construct: TDemo;
begin
  result := TDemo.Create();
  result.FFoo := procedure
                 begin
                   result.foo;
                 end;
end;

编译器无法直接分配方法引用(因为它们是不同类型的),因此(我想)必须将其包装在匿名方法中,该方法会隐式地要求捕获“result”变量。函数返回值不能被匿名方法捕获,只有局部变量可以被捕获。
在您的情况下(或者任何function类型),由于匿名包装器隐藏了result变量,因此等效的表达甚至无法表示,但我们可以在理论上想象相同的情况。
class function TDemo.Construct: TDemo;
begin
  Result := TDemo.Create();
  Result.FVar := function(const L, R : string) : integer
                 begin
                   result := result.CompareInternal(L,R);  // ** can't do this
                 end;
end;

正如David所示,引入一个可以被捕获的局部变量是一种正确的解决方案。另外,如果您不需要TConstFunc类型是匿名的,可以将其声明为常规对象方法:

TConstFunc<T1, T2, TResult> = function(const Arg1: T1; const Arg2: T2): TResult of object;

另一个尝试捕获“result”失败的示例:
program Project1;

{$APPTYPE CONSOLE}

type
  TBar = reference to procedure;
  TDemo = class
  private
    FFoo : Integer;
    FBar : TBar;
  public
    class function Construct(): TDemo;
  end;

class function TDemo.Construct: TDemo;
begin
  result := TDemo.Create();
  result.FFoo := 1;
  result.FBar := procedure
                 begin
                   WriteLn(result.FFoo);
                 end;
end;

end.

这个不起作用的根本原因在于,方法的返回值实际上是一个var参数,而匿名闭包捕获的是变量而不是。这是一个关键点。同样地,下面的写法也是不允许的:

program Project1;

{$APPTYPE CONSOLE}

type
  TFoo = reference to procedure;

  TDemo = class
  private
    FFoo : TFoo;
    procedure Bar(var x : integer);
  end;

procedure TDemo.Bar(var x: Integer);
begin
  FFoo := procedure
          begin
            WriteLn(x);
          end;
end;

begin
end.

[dcc32 错误] Project1.dpr(18): E2555 无法捕获符号“x”

就像原始示例中的引用类型一样,你真正感兴趣的只是捕获引用的值而不是包含它的变量。这并不意味着它在语法上等效,并且编译器为此目的创建一个新变量是不合适的。

我们可以将上面的代码重写成以下形式,引入一个变量:

procedure TDemo.Bar(var x: Integer);
var
  y : integer;
begin
  y := x;
  FFoo := procedure
          begin
            WriteLn(y);
          end;
end;

这是被允许的,但是期望的行为将会非常不同。在捕获变量x时(不被允许),我们期望FFoo总是将传入参数x的当前值写入Bar,无论它何时何地被更改。我们也希望闭包可以使变量即使在创建它的作用域之外仍然保持有效。

然而,在后一种情况下,我们期望FFoo输出变量x的值,这是Bar上次被调用时变量x的值。


回到第一个例子,考虑以下内容:

program Project1;    
{$APPTYPE CONSOLE}    
type
  TFoo = reference to procedure;    
  TDemo = class
  private
    FFoo : TFoo;
    FBar : string;
    procedure Foo;
  public
    class function Construct(): TDemo;
  end;

procedure TDemo.Foo;
begin
  WriteLn('foo' + FBar);
end;

class function TDemo.Construct: TDemo;
var
  LDemo : TDemo;
begin
  result := TDemo.Create();
  LDemo := result;
  LDemo.FBar := 'bar';
  result.FFoo := LDemo.foo;
  LDemo := nil;
  result.FFoo();  // **access violation
end;

var
 LDemo:TDemo;
begin
  LDemo := TDemo.Construct;
end.

以下是明确的内容:

result.FFoo := LDemo.foo;

我们没有为存储在LDemo中的TDemo实例所属的方法foo分配一个正常的引用,而是实际捕获了变量LDemo本身,而不是它在那时所包含的值。之后将LDemo设置为nil会产生访问冲突,即使在进行赋值时所引用的对象实例仍然存在。
如果我们只是将TFoo定义为对象方法而不是引用到过程,那么上述代码的行为将与人们最初期望的相差很大(输出"foobar"到控制台)。

我认为你对编译器如何将方法调用包装为匿名方法的分析是非常准确的。然而,如果编译器能够确定它需要这样做,那么在我看来,它应该能够确定它需要引入一个可以被捕获的变量。 - David Heffernan
无法捕获返回值的原因是它们没有在函数中声明。它们被实现为 var 参数,在所有其他参数之后传递。 - David Heffernan
@DavidHeffernan 是的,引入变量对于引用类型有效,因为您真正关心的只是捕获引用的值,而不是包含它的变量。对于值类型来说,这变得毫无意义。 - J...
2
我同意。我认为你很好地解释了限制背后的实现细节。你应该得到不止我的一票支持。尽管如此,这只是一个实现细节。它是一种对象方法。没有歧义。不应该存在任何变量捕获。编译器应该直接执行。 - David Heffernan
1
@Graymatter 不需要进行变量的捕获。当将方法分配给 ref proc 时,赋值可以是值实例和代码。就像 of object 方法类型一样。设计者选择了另一种方式,但这不是唯一的方式。在本答案的第一段中提到了“魔法”。我建议使用不同的魔法。显然现在为时已晚。任何错误报告都将被关闭为“作为设计”。我只是认为设计选择很差。 - David Heffernan
显示剩余15条评论

11

我英文版Delphi的编译器错误信息如下:

[dcc32 Error] E2555 无法捕获符号'Result'

这是由于设计缺陷引起的。在此处根本不需要进行任何变量捕获。赋值的右侧是一个实例方法而不是匿名方法。但编译器通过将该方法包装在匿名方法中来处理它。编译器将以下内容转换为:

Result.FVar := Result.CompareInternal;

为了

Result.FVar := 
  function(const Arg1, Arg2: string): Integer
  begin
    InnerResult := OuterResult.CompareInternal(Arg1, Arg2);
  end;

暂且不论两个独立的结果变量引起的混乱,编译器拒绝此操作是因为外部结果变量不是一个局部变量,而是一个var参数,因此无法被捕获。

但我认为整个设计都是错误的。没有必要进行任何变量捕获。当你写Result.CompareInternal时,你意图引用一个普通的of object方法。通过更好的设计,编译器将允许这种赋值而不创建匿名方法。

您可以像这样解决问题:

class function TDemo.Construct: TDemo;
var
  Demo: TDemo;
begin
  Demo := TDemo.Create();
  Demo.FVar := Demo.CompareInternal;
  Result := Demo;
end;

这里可以捕获局部变量 Demo

或者,我建议这样写:

program ConsoleDemo1;

{$APPTYPE CONSOLE}

uses
  Generics.Defaults,
  System.SysUtils;

type
  TConstFunc<T1, T2, TResult> = reference to function(const Arg1: T1; 
    const Arg2: T2): TResult;

  TDemo = class(TComparer<string>)
  private
    FVar: TConstFunc<string, string, Integer>;
    function CompareInternal(const L, R: string): Integer;
  public
    constructor Create;
    function Compare(const L, R: string): Integer; override;
  end;

constructor TDemo.Create;
begin
  inherited;
  FVar := CompareInternal;
end;

function TDemo.Compare(const L, R: string): Integer;
begin
  Result := FVar(L, R);
end;

function TDemo.CompareInternal(const L, R: string): Integer;
begin
  Result := AnsiCompareStr(L, R);
end;

end.

4
好的。顺便说一句,这个最小可复现示例非常棒。像这样的最小可复现示例会自动获得我的点赞!如果所有问题都像这样提出就好了!! - David Heffernan
我写这个是为了自己理解它的行为。如果我编译我的原始项目,我需要等待两分钟并且有2GB的内存被占用... - ventiseis
哦,这真是我耳中的音乐!如果每个人都能这样想,那么很多人就能够自己解决更多的问题!这对每个人来说都是更好的选择。 - David Heffernan
2
就我个人而言,我已经在XE4上这样解决了:class function TDemo.Construct: TDemo; begin Result := TDemo.Create; Result.AssignInternalComparer; end;procedure TDemo.AssignInternalComparer; begin FVar := CompareInternal; end; - fantaghirocco
@fantaghirocco 是的,绕过编译器限制的另一种方法,如果你将那个新函数设为inline...但是Construct函数仍然容易出错 :-D - Arioch 'The
@Arioch'The 我同意:整个结构很奇怪,可能会引发错误。但当我用不同的名称(而不是Constructor)替换了两个无参数构造函数并以不同方式初始化FVar时,这是我想到的第一个想法。 - ventiseis

2

这并不是一个完整的答案,而是对David的回答和主题发起者提出的问题的注解。

使用回答模式发布源代码片段。

class function TDemo.Construct: TDemo;
begin
  Result := TDemo.Create();
  Result.FVar := Result.CompareInternal;
end;

class function TDemo.Construct: TDemo;
var
  Demo: TDemo;
begin
  Demo := TDemo.Create();
  Demo.FVar := Demo.CompareInternal;
  Result := Demo;
end;

这两个代码片段使用了同一个模板:

  1. 创建一个对象(并附带内存管理责任)
  2. 调整和优化对象
  3. 将对象传递给外部世界(并附带内存管理责任)

当然,这里的第二点只有一行代码,但是:

  1. 它有一个函数调用,可能会出现错误。如果函数被继承子类虚拟重载,则错误可能会发生两次。
  2. 设计模式不是为了在最简单的情况下工作,而是为了在最困难的情况下工作。

因此,我认为我们应该假设第二点存在运行时错误和异常抛出的风险。那么这就是一个经典的内存泄漏案例。本地函数仍然保持着内存管理责任,因为它没有将结果传递到外部。但是它也没有完成所需的清理工作。

从我的角度来看,正确的模式——并且比仅使用 Result/Result 更具有使用专用本地变量的激励——应该是:

class function TDemo.Construct: TDemo;
var
  Demo: TDemo;
begin

  Demo := TDemo.Create();  // stage 1: creating an object
  try                      // stage 1: accepting M/M responsibilities

     Demo.FVar := Demo.CompareInternal; // stage 2: tuning and facing
     // Demo.xxx := yyy;                //   ...potential risks of exceptions
     // Demo.Connect(zzz);  etc

     Result := Demo;   // stage 3: passing the object outside
     Demo := nil;      // stage 3: abandoning M/M responsibilities
     //  function exit should follow this line immediately, without other fault-risky statements
  finally
    Demo.Free;         // proceeding with M/M in case of faults in stage 2
  end;
end;                   // stage 3: passing the object outside - immediately after the assignments!

更新:ventiseis:另外需要注意的是:我会尝试仅实例化配置的比较器TDemo一次。比较函数应该是无状态的函数。

  TDemo = class(TComparer<string>)
  private
    class var FVar: TConstFunc<string, string, Integer>;
   // function CompareInternal(const L, R: string): Integer; STATIC; // also possible
    class constructor InitComp;
  ...
  end;

  // would only be called once, if the class is actually used somewhere in the project
  class constructor TDemo.InitComp; 
  begin
    FVar := function(const L, R: string): Integer
    begin
      Result := StrToInt(R) - StrToInt(L)
    end 
  end;

我同意,如果出现任何异常,都会创建一个内存泄漏。我从来没有自己想出过这种try/finally模式 - 基本上这是个好主意,但我认为有一些危险会忘记Demo := nil;。为什么不使用try .. except on oE: FreeAndNil(Demo); raise oE; end;呢? - ventiseis
另外一点需要注意的是:我建议只实例化配置好的比较器TDemo一次。比较函数应该是一个无状态的函数,适用于任何列表或数组,因此创建和销毁比较器似乎没有任何好处。 - ventiseis
1
@ventiseis,可能可以简单地写成...except Demo.Free; raise; end。好吧,答案实际上是一样的:“有一些危险”,可能会忘记重新引发异常。你还忘了begin-end :) 所以-选择你的毒药,它们很相似。此外,我的代码片段提供了一个额外的选项(例如在查找或nil函数中,如TDataSet.FindField),可以删除临时对象并返回nil而不引发异常。 - Arioch 'The
@ventiseis 如果是这样,TDemo.FVar 应该在 类构造函数 中被赋值为 类变量 - Arioch 'The
1
@ventiseis它返回不同的类,就像Delphi的TEncoding一样,后者也是在.Net之后设计的(在我看来这是不正确的,因为Delphi本身不是基于GC的!TEncoding应该是一个接口,而不是一个类!)所以就像使用TEncoding一样,您需要制作function TDemo.Compare(const L, R: string): Integer; VIRTUAL; ABSTRACT;,然后制作一堆具有不同实现的function TDemoNNN.Compare(const L, R: string): Integer; override;的类,比如TDemo1, TDemo2, TDemo3。然后返回像DotNet示例一样的不同类。 - Arioch 'The
显示剩余6条评论

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