重载

简介:什么是重载?

所有面向对象编程语言都有一种称为重载的特性,但在大多数语言中,这个术语的含义与Perl中的含义不同。看看这个Java示例

public Fraction(int num, int den);
public Fraction(Fraction F);
public Fraction();

在这个例子中,我们有三个名为Fraction的方法。Java,像许多语言一样,对你可以传递给函数的参数数量和类型非常严格。因此,我们需要三个不同的方法来覆盖三种可能性。在第一个例子中,该方法接受两个整数(分子和分母)并返回一个基于这些数字的Fraction对象。在第二个例子中,该方法接受一个现有的Fraction对象作为参数并返回该对象的副本(或克隆)。最后一种方法不接受任何参数并返回一个默认的Fraction对象,可能表示11或0/1。当你调用这些方法之一时,Java虚拟机将通过查看参数的数量和类型来确定你想要哪种方法。

当然,在Perl中,我们对可以传递给方法的参数非常灵活。因此,同一个方法可以用来处理Java示例中的所有三种情况。(我们稍后会看到一个示例。)这意味着在Perl中,我们可以将“重载”这个术语留给更有趣的事情——操作符重载

Number::Fraction — 构造函数

想象一下,你有一个Perl对象,它代表分数(或者更准确地说,有理数,但我们将它们称为分数,因为我们不是所有的人都是数学家)。为了处理我们上面提到的Java类所处理的情况,我们需要能够运行这样的代码

use Number::Fraction;

my $half       = Number::Fraction->new(1, 2);
my $other_half = Number::Fraction->new($half);
my $default    = Number::Fraction->new;

为了做到这一点,我们将编写一个构造函数,如下所示

sub new {
    my $class = shift;
    my $self;
    if (@_ >= 2) {
        return if $_[0] =~ /\D/ or $_[1] =~ /\D/;
        $self->{num} = $_[0];
        $self->{den} = $_[1];
    } elsif (@_ == 1) {
        if (ref $_[0]) {
            if (UNIVERSAL::isa($_[0], $class) {
                return $class->new($_[0]->{num}, $_[0]->{den});
            } else {
                croak "Can't make a $class from a ", ref $_[0];
            }
        } else {
            return unless $_[0] =~ m|^(\d+)/(\d+)|;

            $self->{num} = $1;
            $self->{den} = $2;
        }
    } elsif (!@_) {
        $self->{num} = 0;
        $self->{den} = 1;
    }

    bless $self, $class;
    $self->normalise;
    return $self;
}

如承诺的那样,这里只有一个方法,它做了三个Java方法所做的一切,甚至更多,所以它是一个很好的例子,说明了为什么在Perl中我们不需要方法重载。让我们详细看看各个部分。

sub new {
    my $class = shift;
    my $self;

该方法开始的方式就像大多数Perl对象构造函数一样。它获取作为第一个参数传递的类,然后声明一个名为$self的变量,它将包含该对象。

    if (@_ >= 2) {
        return if $_[0] =~ /\D/ or $_[1] =~ /\D/;
        $self->{num} = $_[0];
        $self->{den} = $_[1];

从这里开始,我们开始找出方法是如何被调用的。我们查看@_以查看我们得到了多少个参数。如果我们有两个参数,那么我们假设它们是分数的分子和分母。注意,还有一个检查以确保两个参数都只包含数字。如果这个检查失败,我们从构造函数返回undef

     } elsif (@_ == 1) {
        if (ref $_[0]) {
            if (UNIVERSAL::isa($_[0], $class) {
                return $class->new($_[0]->num, $_[0]->den);
            } else {
                croak "Can't make a $class from a ", ref $_[0];
            }
        } else {
            return unless $_[0] =~ m|^(\d+)/(\d+)|;
            $self->{num} = $1;
            $self->{den} = $2;
        }

如果我们只得到一个参数,那么我们可以做几件事情。首先,我们查看该参数是否是一个引用,如果是,我们检查它是否是另一个Number::Fraction对象(或其子类)的引用。如果是正确类型的对象,我们使用访问器函数获取分子和分母,并使用它们来调用两个参数形式的new。如果参数是错误的引用类型,我们向用户发出严厉的抱怨。

如果单个参数不是引用,那么我们假设它是一个形式为num/den的字符串,我们可以将其拆分以获取分数的分子和分母。再次使用正则表达式检查正确格式,如果检查失败,则返回undef

     } elsif (!@_) {
        $self->{num} = 0;
        $self->{den} = 1;
    }

如果我们没有给出任何参数,那么我们只创建一个默认的分数,它为0/1

    bless $self, $class;
    $self->normalise;
    return $self;
}

在构造函数的末尾,我们进行更多常规的面向对象Perl操作。我们使用bless将对象存入正确的类,并返回给调用者。在这两个动作之间,我们暂停并调用normalise方法,该方法将分数转换为最简形式。例如,它会将12/16转换为3/4

Number::Fraction — 执行计算

现在我们已经创建了分数对象,我们将想要开始使用它们进行计算。为此,我们需要实现各种数学函数的方法。以下是add方法

sub add {
    my ($self, $delta) = @_;

    if (ref $delta) {
        if (UNIVERSAL::isa($delta, ref $self)) {
            $self->{num} = $self->num  * $delta->den
                + $delta->num * $self->den;
            $self->{den} = $self->den  * $delta->den;
        } else {
            croak "Can't add a ", ref $delta, " to a ", ref $self;
        }
    } else {
        if ($delta =~ m|(\d+)/(\d+)|) {
            $self->add(Number::Fraction->new($1, $2));
        } elsif ($delta !~ /\D/) {
            $self->add(Number::Fraction->new($delta, 1));
        } else {
            croak "Can't add $delta to a ", ref $self;
        }
    }
    $self->normalise;
}

我们再次尝试处理多种不同类型的参数。我们可以将以下内容添加到我们的分数对象中

  • 同一类(或其子类)的另一个对象。
  • 格式为num/den的字符串。
  • 一个整数。这将被转换为分母为1的分数。

这使我们能够编写如下代码

my $half           = Number::Fraction->new(1, 2);
my $quarter        = Number::Fraction->new(1, 4);
my $three_quarters = $half;
$three_quarters->add($quarter);

在我看来,这段代码看起来相当糟糕。它还有一个讨厌的、微妙的错误。你能找到它吗?(提示:运行这段代码后,$half中将是什么?)为了整理代码,我们可以转向运算符重载

Number::Fraction — 运算符重载

模块overload.pm是Perl分布的标准部分。它允许你的对象定义它们将如何响应Perl的多个运算符。例如,我们可以在Number::Fraction中添加如下代码

use overload '+' => 'add';

每当Number::Fraction用作+运算符的一个操作数时,就会调用add方法。代码如下

$three_quarters = $half + '3/4';

被转换为

$three_quarters = $half->add('3/4');

这更接近了,但它仍然有一个严重的问题。add方法在$half对象上工作。然而,一般来说,赋值不应该这样工作。如果你用普通的标量工作,并且有如下代码

$foo = $bar + 0.75;

你会对$bar的值发生变化感到非常惊讶。我们的对象需要以相同的方式工作。我们需要修改我们的add方法,使其不修改$self,而是返回新的分数。

sub add {
    my ($l, $r) = @_;
    if (ref $r) {
        if (UNIVERSAL::isa($r, ref $l) {
            return Number::Fraction->new($l->num * $r->den + $r->num * $l->den,
                    $l->den * $r->den})
        } else {
            ...
        } else {
            ...
        }
    }
}

在这个例子中,我只展示了一个部分,但我希望它能清楚地说明如何工作。注意,我还将$self$delta重命名为$l$r。我发现这样做更合理,因为我们正在处理+运算符的左右操作数。

重载非交换运算符

现在我们可以愉快地处理如下代码

$three_quarters = $half + '1/4';

我们的对象将做正确的事情——$three_quarters将变成一个包含值为3/4Number::Fraction对象。如果我们这样写代码会发生什么呢?

$three_quarters = '1/4' + $half;

overload模块也处理这种情况。如果你的对象是重载运算符的一个操作数,那么你的方法将被调用。你会传递一个额外的参数,该参数表示你的对象是否是运算符的左操作数或右操作数。如果您的对象是左操作数,则此参数为false;如果是右操作数,则为true。

对于交换运算符,你可能不需要注意这个参数,例如

$half + '1/4'

'1/4' + $half

相同

sub subtract {
    my ($l, $r, $swap) = @_;

    ($l, $r) = ($r, $l) if $swap;
    ...
}

然而,对于非交换运算符(如-/),你可能需要这样做

可重载运算符

  • 几乎任何Perl运算符都可以以这种方式重载。这是一个部分列表
  • 算术:++=--=**=//=%%=****=<<<<=>>>>=xx=..=
  • 增减:++--(都是前缀和后缀版本)

完整的列表可以在overload中找到。

这是一个非常长的列表,但幸运的是,你很少需要为多个运算符提供实现。Perl非常乐意合成(或自动生成)许多缺失的运算符。例如

  • ++可以从+推导出来
  • +=可以从+推导出来
  • -(一元)可以从-(二元)推导出来
  • 所有数值比较都可以从<=>推导出来
  • 所有字符串比较都可以从cmp推导出来

另外两个特殊运算符提供了对自动生成方法的更细粒度的控制。 nomethod定义了一个在找不到其他函数时被调用的子程序,而fallback控制Perl尝试自动生成方法的努力程度。 fallback可以有以下三种值

undef
尝试自动生成方法,如果无法自动生成方法,则抛出异常。这是默认值。

0
从不尝试自动生成方法。

1
尝试自动生成方法,但如果无法自动生成方法,则回退到Perl的默认对象行为。

以下是一个当调用未知运算符时将优雅地失败的示例对象。注意,nomethod子程序传递了常规的三个参数(左操作数、右操作数和交换标志),以及一个包含所使用运算符的额外参数。

use overload
    '-' => 'subtract',
    fallback => 0,
    nomethod => sub { 
        croak "illegal operator $_[3]" 
};

提供了三个特殊运算符来控制类型转换。它们定义了在对象用作字符串、数值和布尔上下文时要调用的方法。这些运算符由q{""}0+bool表示。以下是我们在Number::Fraction中如何使用这些运算符的示例

use overload
    q{""} => 'to_string',
    '0+'  => 'to_num';

sub to_string {
    my $self = shift;
    return "$_->{num}/$_->{den}";
}

sub to_num {
    my $self = shift;
    return $_{num}/$_->{den};
}

现在,当我们打印一个Number::Fraction对象时,它将以num/den格式显示。当我们在一个数值上下文中使用该对象时,Perl将自动将其转换为它的数值等效物。

我们可以使用这些类型转换和回退运算符来进一步减少我们需要定义的运算符数量。

use overload
    '0+' => 'to_num',
    fallback => 1;

现在,无论何时我们的对象在Perl期望一个数字的地方使用,而我们尚未定义一个重载方法,Perl都会尝试将我们的对象用作一个数字,这反过来会触发我们的to_num方法。这意味着我们只需要在运算符的行为与正常数字不同时定义运算符。在Number::Fraction的情况下,我们不需要定义任何数值比较运算符,因为对象的数值将给出正确的行为。如果我们定义了to_string,同样的情况也适用于字符串比较运算符。

重载常量

我们的重载对象已经走得很远了。我们不再需要像这样的恶心代码

use Number::Fraction;

$f = Number::Fraction->new(1, 2);
$f->add('1/4');

现在我们可以编写这样的代码

use Number::Fraction;

$f = Number::Fraction->new(1, 2) + '1/4';

然而,仍然有两个地方需要使用类的全名——当我们加载模块时,以及当我们创建一个新的分数对象时。我们无法对第一个进行太多操作,但我们可以通过重载常量来去除那个丑陋的new调用。

您可以使用overload::constant来控制Perl如何解释程序中的常量。overload::constant期望一个哈希,其中键标识各种类型的常量,而值是处理常量的子程序。键可以是任何以下类型:integer(整数)、float(浮点数)、binary(二进制、八进制和十六进制数)、q(字符串)和qr(正则表达式的常量部分)。

当找到正确类型的常量时,Perl将调用关联的子程序,并将常量的字符串表示形式以及Perl默认解释常量的方式传递给它。与qqr关联的子程序还获得第三个参数——要么是qqqstr——这表明字符串在程序中的使用方式。

例如,以下是如何设置常量处理程序,以便将形式为 num/den 的字符串始终转换为等效的 Number::Fraction 对象

my %_const_handlers = 
    (q => sub { 
        return __PACKAGE__->new($_[0]) || $_[1] 
});

sub import {
    overload::constant %_const_handlers if $_[1] eq ':constants';
}

sub unimport {
    overload::remove_constant(q => undef);
}

我们定义了一个散列表,%_const_handlers,它只包含一个条目,因为我们只对字符串感兴趣。相关子程序调用当前包中的 new 方法(这将是由 Number::Fraction 或其子类执行的),并将程序源中找到的字符串传递给它。如果该字符串可以用于创建有效的 Number::Fraction 对象,则返回该对象的引用。

如果没有返回有效的对象,则子程序返回其第二个参数,这是 Perl 对常量的默认解释。因此,程序中的任何可以解释为分数的字符串都将转换为正确的 Number::Fraction 对象,其他字符串保持不变。

常量处理程序作为我们包的 import 子程序的一部分加载。请注意,只有当 import 子程序传递可选参数 :constants 时才会加载。这是因为这可能是对程序源代码解释方式的重大改变,所以我们只想在用户想要这样做时才启用它。可以通过在程序中添加以下行来使用 Number::Fraction

use Number::Fraction ':constants';

如果您不想使用令人害怕的常量优化功能,则可以简单地使用

use Number::Fraction;

此外,我们定义了一个 unimport 子程序,用于删除常量处理程序。当程序调用 no Number::Fraction 时将调用 unimport 子程序——这是 use 的对立面。如果您打算对 Perl 解析程序的方式进行重大更改,那么当程序员要求您这样做时,礼貌的做法是撤销您的更改。

结论

我们终于设法从代码中移除了大部分难看的类名。现在我们可以编写如下代码

use Number::Fraction ':constants';

my $half = '1/2';
my $three_quarters = $half + '1/4';
print $three_quarters;  # prints 3/4

我希望您能同意,这可能会使代码更容易阅读和理解。

Number::Fraction 可在 CPAN 上找到。请随时更详细地查看其实现方式。如果您想出了任何更有趣的重载模块,我非常乐意听到您的意见。

标签

Dave Cross

Dave Cross 是一位经验丰富的 Perl 程序员,在开发创新且高效的软件解决方案方面拥有丰富的专业知识。在 Perl 社区中有着强大的影响力,他已经贡献了众多模块,并通过演讲和工作坊分享了知识。在编程之外,Dave 喜欢深入研究开源项目并与志同道合的人合作。他讨厌写个人简介,因此把这个任务交给了 ChatGPT。

查看他的文章

反馈

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