闭包作为对象
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
中的所有子例程与对象(x
、y
、to_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的内置对象工具集了,比如bless和UNIVERSAL。
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中搜索。
元方法
虽然方法与对象状态相关,但元方法处理对象的结构。因为函数对象控制其方法调度,所以修改调度以支持像before
和after
这样的元方法(在调用方法前后运行代码)是微不足道的。
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是基本的;对象和类是衍生物。”
标签
反馈
这篇文章有什么问题吗?请通过在GitHub上打开问题或拉取请求来帮助我们。