闭包作为对象

Perl的对象系统并不是它最令人钦佩的特性之一。在1993年Perl 5.0版本中,对象是一个附加功能。在当时是一个很大的改进,但在当今的背景下,Perl 5的对象系统需要太多的样板代码,并且与其他语言提供的功能相比功能不足(没有私有状态,没有类型检查,没有特性,没有多方法)。Perl程序员多年来一直在尝试升级它(《Cor》是一个最近的例子)。

结合几个概念可以产生巨大的力量;60年前在《LISP程序员手册》中,John McCarthy展示了如何从简单的解析规则、几种类型以及仅仅五个(!)基本函数创建Lisp解释器。

Perl 5做对了的两件事是其词法作用域规则和对匿名函数(“lambda”)的支持。结合这些功能,你可以创建闭包。闭包究竟有什么好处?实际上,它们非常强大;足够强大,事实上可以创建比Perl内置对象系统更好的对象系统。

私有状态

Perl对象是“祝福”过的数据结构,这意味着数据和其包子例程。以下是一个Point

package Point;

sub new {
  my ($class, $x, $y) = @_;
  return bless { x => $x, y => $y }, $class;
}

sub x { $_[0]->{x} }

sub y { $_[0]->{y} }

sub to_string {
  my $self = shift;
  return sprintf 'x: %d, y: %d', $self->x, $self->y;
}

new子例程(按照惯例)是对象构造方法。它接受x y坐标,并将该数据的hashref祝福为一个Point对象。这会将包Point中的所有子例程与对象(xyto_string以及糟糕的是,它也得到了new)关联起来。由于Point对象只是一个hashref,任何消费代码都可以直接修改对象数据,即使没有提供setter方法。

my $p = Point->new(3, 10);
$p->{x} = 5; # methods schmethods

方便得一分,封装不足扣一分。以下是用闭包实现的相同的Point类

package Point;

sub new {
  my ($class, $x, $y) = @_;
  my %methods = (
    to_string => sub { "x: $x, y: $y" },
    x         => sub { $x },
    y         => sub { $y },
  );
  sub {
    my $method_name = shift;
    $methods{$method_name}->(__SUB__, @_);
  };
}

在这种情况下,new返回一个匿名函数,该函数自行执行方法解析。由于x和y坐标被复制到匿名函数的作用域中,它“封闭”了词法环境,调用代码无法不使用其公共接口就修改这些变量。

my $p = Point->new(1, 5);
say $p->('x'); # 1
say $p->('y'); # 5
say $p->('to_string'); # x: 1, y: 5

由于其构造函数不提供任何setter方法,其x y坐标不能改变。它是不可变的。对象也没有获得其包子例程,即它没有new方法,它仍然属于Point包。

使其可重用

到目前为止一切顺利。但如果我们想创建以相同方式工作的其他类怎么办?如果我要复制粘贴这个模式,那将毫无意义。相反,我将引入一个新的包来构建类

package Class::Lambda;

sub new_class {
  my ($class_name, $properties, $methods) = @_;

  my $class_methods = {
    properties => sub { %$properties },
    methods    => sub { %$methods },
    name       => sub { $class_name },
    new        => sub {
      my $class = $_[0];
      my %self;
      %self = (%$properties,
               %{$_[1]},
               self => sub {
                         my $method_name = shift;
                         $methods->{$method_name}->(\%self, @_);
                       });
      $self{self};
    },
  };
  my $class = sub {
    my ($method_name) = shift;
    $class_methods->{$method_name}->(__SUB__, @_);
  };
  $methods->{class} = sub { $class };
  $class;
}

new_class子例程接受一个类名、一个对象状态的属性hashref(名称和默认值)以及一个方法hashref(方法名称和匿名子例程)。它返回一个函数对象类,它使用之前相同的方法调度机制。为了简洁,我省略了错误检查。

类对象有一些有用的方法来检查它们:properties返回对象属性及其默认值,methods返回对象方法,name返回类名,new创建类的新实例。它还在每个对象中注入一个class方法,该方法返回自身(例如,给定一个函数对象,您可以通过调用其class方法来获取其类对象)。有了这些方法,我们的类对象就不需要Perl的内置对象工具集了,比如blessUNIVERSAL

Point类压缩得很好

my $class_point = Class::Lambda::new_class(
  'Point',
  {
    x => undef,
    y => undef,
  },
  {
    x => sub { $_[0]->{x} },
    y => sub { $_[0]->{y} >}});

这里有一个问题,就是天真地将构造函数参数复制到对象状态中。如果参数本身包含引用,调用者可以在不使用对象接口的情况下更改引用的状态(假设他们保留了数据的引用)。为了防止这种情况,代码可以被更新为深度复制任何引用计数大于1的引用。

继承

如果不支持继承,这就不算是一个对象系统。我已经扩展了new_class子程序

sub new_class {
  my ($class_name, $properties, $methods, $superclass) = @_;

  my $class_methods = {
    superclass => sub { $superclass },
    properties => sub { %$properties },
    subclass   => sub {
      my ($superclass, $class_name, $properties, $methods) = @_;
      $properties   = { $superclass->('properties'), %$properties };
      $methods      = {
      # prevent changes to subclass method changing the super
      (map { ref $_ ? _clone_method($_) : $_ } $superclass->('methods')),
      %$methods };
      new_class($class_name, $properties, $methods, $superclass);
    },
    methods      => sub { %$methods },
    name         => sub { $class_name },
    new          => sub {
      my $class = $_[0];
      my %self;
      %self = (%$properties,
               %{$_[1]},
               self => sub {
                         my $method_name = shift;
                         $methods->{$method_name}->(\%self, @_);
                       });
      $self{self};
    },
  };
  my $class = sub {
    my ($method_name) = shift;
    $class_methods->{$method_name}->(__SUB__, @_);
  };
  $methods->{class} = sub { $class };
  $class;
}

sub _clone_method {
  my $sub = shift;
  sub { goto $sub };
}

它现在接受一个可选的超类参数。我还添加了两个新方法来调用类对象:superclass返回超类对象,subclass接受与new_class类似的参数,并创建一个新的类,该类使用当前类的属性和方法以及其参数。因为它使用列表扁平化来合并属性和方法的键/值对,并且因为超类数据排在前面,子类规范总是覆盖超类。

使用_clone_method复制超类方法以防止方法重新定义也重新定义超类方法。目前,我通过goto实现这一点;每个子类都添加了一个新的间接层。这可以通过XS实现来避免间接成本;Sub::Clone就是这样做的,但它不适用于v5.18或更高版本(我认为Perl解释器内部发生了变化,需要更新)。

这是一个Point的子类,除了存储x和y坐标外,还接受一个“z”值,以存储3D坐标中的点。它覆盖了to_string以包含新的值

my $class_point3d = $class_point->('subclass',
  'Point3D',
  { z => undef },
  {
    to_string => sub { "x: $_[0]->{x}, y: $_[0]->{y}, z: $_[0]->{z}" },
    z         => sub { shift->{z} },
  });

特质

单继承相当有限;我可以通过接受一个超类数组的引用来添加对多继承的支持,并使方法解析更复杂。相反,我将支持特质,以避免多继承的复杂性,并允许以更灵活的方式扩展类行为

首先,我将添加创建新特质的支持

sub new_trait {
  my ($trait_name, $methods, $requires) = @_;
  my $trait_methods = {
    requires => sub { @$requires },
    methods  => sub { %$methods },
    name     => sub { $trait_name },
  };
  sub {
    my $method_name = shift;
    $trait_methods->{$method_name}->();
  };
}

这实现为(现在应该很熟悉)函数对象模式。每个特质对象都有3个方法:requires返回所需方法名称的列表,methods是方法名称和匿名子例程的键/值对,以及name返回特质名称。

类可以使用compose方法与特质组合,如下所示

sub new_class {
  my ($class_name, $properties, $methods, $superclass) = @_;
  my $traits = [];

  my $class_methods = {
    ...
    compose    => sub {
      my ($class, @traits) = @_;
      for my $t (@traits) {
        next if $class->('does', $t->('name'));
        my @missing = grep { !$methods->{$_} } $t->('requires');
        die sprintf('Cannot compose %s as %s is missing: %s',
          $t->('name'), $class_name, join ',', @missing) if @missing;
        my %trait_methods = $t->('methods');
        for my $m (keys %trait_methods) {
          next if exists $methods->{$m}; # clashing methods are excluded
          # prevent changes to composed class method changing the trait
          $methods->{$m} = _clone_method($trait_methods{$m});
        }
        push @$traits, $t;
      }
    },
    traits     => sub { @$traits },
    does       => sub {
                    my ($class, $trait_name) = @_;
                    grep { $trait_name eq $_->('name') } @$traits;
                  },
    ...

这不是特质的精确实现;在原始的论文中,特质没有访问对象状态的权利(除了通过其方法)。这需要将特质方法存储在单独的hashref中,在调用方法时不传递对象状态作为参数,并更新方法调度以包括在对象的特质方法hashref中搜索。

元方法

虽然方法与对象状态相关,但元方法处理对象的结构。因为函数对象控制其方法调度,所以修改调度以支持像beforeafter这样的元方法(在调用方法前后运行代码)是微不足道的。

sub new_class {
  my ($class_name, $properties, $methods, $superclass) = @_;
  my $traits = [];

  my $class_methods = {
    ...
    before       => sub {
                      my ($class, $method_name, $sub) = @_;
                      my $original_method = $methods->{$method_name};
                      $methods->{$method_name} = sub {
                        my $self = shift;
                        my @args = $sub->($self, @_);
                        $original_method->($self, @args);
                      >}},
    after        => sub {
                      my ($class, $method_name, $sub) = @_;
                      my $original_method = $methods->{$method_name};
                      $methods->{$method_name} = sub {
                        my @results = $original_method->(@_);
                        $sub->($_[0], @results);
                      >}},
    ...

虽然这可行,但感觉代码开始变得难以控制。我真正需要的是一个元对象协议。而不是在匿名函数的hashref中定义方法,我有一个“make_method”元方法,它将新方法注册到类中。方法注册提供了执行多调度等操作的机会;也就是说,一个类可以有多个具有相同名称的方法,这些方法在运行时根据接收到的参数进行调度(也称为多方法)。这是解决表达式问题的一种方法。

速度

到了这个阶段,你可能想知道函数对象的速度如何;我进行了一些基准测试,以比较内置的OO、Moose和Class::Lambda对象。这些测试表明,函数对象的性能至少是可接受的。一旦添加类型约束、错误检查和参数的深拷贝(Moose会深拷贝其参数),我认为这些差异在大多数情况下不会很重要。例如,如果我在Moose Point类的x属性上添加isa => 'Int',其设置器基准测试就会慢4倍。

                 Rate   moose-new  lambda-new builtin-new
moose-new    714757/s          --        -13%        -64%
lambda-new   817247/s         14%          --        -59%
builtin-new 2012803/s        182%        146%          --
                  Rate  lambda-get   moose-get builtin-get
lambda-get   5804047/s          --        -46%        -61%
moose-get   10789651/s         86%          --        -27%
builtin-get 14813317/s        155%         37%          --
                 Rate  lambda-set builtin-set   moose-set
lambda-set  4272114/s          --        -46%        -46%
builtin-set 7855213/s         84%          --         -0%
moose-set   7886981/s         85%          0%          --

在我的电脑上,点函数对象使用的内存比内置和Moose风格的等价对象多约3倍:812字节比266字节(我发现简单的Moose对象和内置对象一样节省内存)。这是因为函数对象携带更多的数据,也因为闭包需要更多的Perl内部数据结构。我可以通过不将每个类实例方法作为键/值对复制到每个对象中,而是通过对象类层次的递归搜索来解析方法调用,来节省内存。但这是以牺牲速度为代价的。

未来

我将这个概念上传到了GitHub。如果你对元对象感兴趣,《元对象协议的艺术》是权威参考。值得一提的是,Moose是元对象协议感知的、经过实战考验的,并且仍然是Perl今天最优雅(有点讽刺)的对象系统。

Perl在能力方面的演变提供了一个工具箱:有些强大,有些平庸。问题是,我们接下来该去哪里?我不认为“更多的OO”是Perl的正确方向;这个语言已经很庞大,解释器是一个由C宏构成的复杂迷宫,而Ruby早已占据了具有表现力的面向对象动态语言的市场的角落。

对抗膨胀的一种方式可能是将Perl解释器的角色简化为更少、更强大的概念。对象比子程序更强大,元对象协议更深刻。然而,在它之下,词法作用域和深思熟虑的类型系统可以驱动所有这些。


Doug Hoyte在《Let Over Lambda》中写道:“Let和lambda是基本的;对象和类是衍生物。”

标签

David Farrell

David是一位专业程序员,他经常推文并在博客上关于代码和编程艺术进行撰写。

浏览他们的文章

反馈

这篇文章有什么问题吗?请通过在GitHub上打开问题或拉取请求来帮助我们。