理解子例程的意义

编者按:本文在高级子例程技术中有所续写。

子例程(或过程、函数、程序、宏等)本质上是一段命名的作业。它是一种简写,允许您以更大的块来思考您的问题。更大的块意味着两件事

  • 您可以将问题分解为更小的、可以独立解决的问题。
  • 您可以使用这些解决方案更有信心地解决您的问题。

编写良好的子例程将使您的程序更小(在行数和内存方面),更快(在编写和执行方面),更少出错,并且更容易修改。

您在开玩笑吧,对吧?

考虑一下:当您拿起三明治咬一口时,您不会考虑所有用于收缩肌肉和协调动作的工作,以防止蛋黄酱弄到您的头发上。本质上,您执行了一系列子例程,告诉“把三明治举到我的嘴里咬一口,然后把它放回盘子上。”如果您每次想咬一口时都要思考所有的肌肉收缩和协调,您会饿死的。

代码也是如此。我们编写程序是为了人类的利益。计算机不在乎您的代码是复杂还是简单,它将所有内容都转换为相同的1和0,无论它是完美的缩进还是全都在一行。编程指南和几乎所有编程语言功能都是为了人类的利益而存在的。

了解更多

子例程确实是所有程序疾病的神奇良药。当正确使用时,您会发现您编写程序的时间缩短了一半,您对所写内容的信心更大,并且更容易向他人解释。

命名

子例程为一系列步骤提供了一个名称。这对于处理复杂的过程(或算法)尤为重要。这包括像Guttler-Rossman变换(用于排序)这样的象牙塔解决方案,也包括您公司做应收账款过于复杂的方式。通过给它起一个名字,您使其更容易处理。

代码重用

面对现实吧——您将在代码的不同部分重复做同样的事情。如果您在40个地方有相同的30行代码,就很难应用错误修复或需求变更。更好的是,如果您的代码使用子例程,就更容易优化那个使整个应用程序变慢的小部分。研究表明,应用程序的运行时间通常在应用程序代码的1%以内发生。如果那1%在几个子例程中,您就可以优化它,并隐藏其他代码中的讨厌细节。

可测试性

对许多人来说,“测试”是一个四个字母的词。我坚信这是因为他们没有足够的接口来测试。子例程提供了一种方法,可以抓取您代码的一部分,并独立于所有其他代码对其进行测试。这种独立性对于现在和将来的测试信心至关重要。

此外,当有人发现一个错误时,错误通常只会出现在一个子例程中。当这种情况发生时,您可以更改那个子例程,而不会改变整个系统。对应用程序所做的更改越少,对修复不会引入新错误的信心就越大。

开发便利性

没有人会争论,当有十个开发者参与一个项目时,子程序不好。它们允许不同的开发者并行工作在应用程序的不同部分。(如果有依赖关系,一个开发者可以模拟缺少的子程序。)然而,对于单个开发者来说,它们也能提供相同的好处:它们允许你专注于应用程序的一个特定部分,而无需构建所有组件。当你不得不阅读你六个月前写的代码时,你会为选择的好名字感到高兴。

考虑以下复杂的条件判断示例

if ((($x > 3 && $x<12) || ($x>15 && $x<23)) &&
    (($y<2260 && $y>2240) || ($z>foo_bar() && $z<bar_foo()))) {

很难确切知道发生了什么。一些明智的空白可以有所帮助,改进布局也可以。那么

if (
     (
       ( $x > 3 && $x < 12) || ($x > 15 && $x < 23)
     )
     &&
     (
       ($y < 2260 && $y > 2240) || ($z > foo_bar() && $z < bar_foo())
     )
   )
{

哎呀,这几乎更糟糕。进入子程序来拯救

sub is_between {
    my ($value, $left, $right) = @_;

    return ( $left < $value && $value < $right );
}

if (
    ( is_between( $x, 3, 12 ) ||
      is_between( $x, 15, 23 )
    ) && (
      is_between( $y, 2240, 2260 ) ||
      is_between( $z, foo_bar(), bar_foo() )
    ) {

这样读起来容易多了。需要注意的是,在这种情况下,重写实际上并没有节省任何字符。事实上,这比原始版本略长。然而,它更容易阅读,这使得它更容易进行正确性验证以及安全修改。(在为文章编写此子程序时,我实际上发现了一个错误——我比较$y的值时颠倒了,所以$y的条件判断永远不会为真。)

我怎样才能知道我做得对呢?

就像有好的三明治(火鸡俱乐部在深色黑麦面包上)和坏的三明治(花生酱和香蕉在维珍面包上)一样,也有好的和坏的三明治。虽然编写好的子程序在很大程度上是一种艺术形式,但在编写好的子程序时,你可以寻找一些特点。一个好的子程序是可读的,并且具有定义良好的接口、强大的内部凝聚力和松散的外部耦合。

可读性

最好的子程序是简洁的——通常25-50行,相当于一到两个平均屏幕的高度。(虽然你的屏幕可能高达110行,但你总有一天需要在星期天的凌晨3点在一个VT100终端上调试你的代码。)

可读性的一个部分也意味着代码没有过度缩进。Linux内核代码的指南包括一条声明,即所有代码宽度应小于80个字符,缩进应宽8个字符。这是为了防止超过三层缩进。超过这个层次,很难跟踪逻辑流程。

定义良好的接口

这意味着你知道所有的输入和输出。这样做允许你在这一边的墙上做手脚,只要保持合同,你就有保证代码的另一侧接口不会受到损害。这对良好的测试也很关键。通过有一个稳定的接口,你可以编写测试套件来验证子程序,并模拟子程序来测试使用它的代码。

强大的内部凝聚

内部凝聚是关于子程序内部的代码行之间如何相互关联。理想情况下,一个子程序只做一件事。这意味着调用子程序的人可以确信它只会做他们想要做的事。

松散的外部耦合

这意味着子程序外部的代码更改不会影响子程序的执行,反之亦然。这允许你在子程序内部安全地做出更改。这也称为没有副作用。

例如,一个松散耦合的子程序不应该不必要地访问全局变量。对于你在子程序中创建的任何变量,使用my关键字进行适当的范围是至关重要的。

这也意味着子程序应该能够在不依赖于其他子程序在它之前或之后运行的情况下运行。在函数式编程中,这意味着函数是无状态的

Perl有全局特殊变量(如$_@_$?$@$!)。如果你修改它们,请确保使用local关键字将其局部化。

我应该叫什么名字?

为事物命名得当对于代码的各个部分都至关重要。在子程序方面,这更为重要。子程序是一段仅通过其名称向读者描述的工作。如果名称太短,没有人知道它的意思。如果名称太长,那么它难以理解,并且可能难以输入。如果名称过于具体,你在更一般的情况下调用它时,将会让读者感到困惑。

子程序名称在朗读时应流畅:用于动作的 doThis() 和用于布尔检查的 is_that()。理想情况下,子程序名称应该是 verbNoun()(或 verb_noun())。为了测试这一点,你可以朗读一段代码给你的非技术朋友听。当你读完后,问他们这段代码应该做什么。如果他们毫无头绪,你的子程序(和变量)可能命名不当。(我已经提供了两种形式的示例,“camelCase” 和 “under_score”。有些人喜欢一种方式,有些人喜欢另一种方式。只要你保持一致,选择哪种方式并不重要。)

我还能做什么?

(本节假设您对 Perl 基础知识有很好的掌握,尤其是哈希和引用。)

Perl 是一类允许您将子程序视为一等对象的编程语言之一。这意味着您可以在几乎可以使用变量的任何地方使用子程序。这个概念来自函数式编程(FP),是一个非常强大的技术。

FP 在 Perl 中的基本构建块是子程序的引用,或 subref。对于命名子程序,您可以说 my $subref = \&foobar;。然后您可以说 $subref->(1, 2),就像您说了 foobar(1, 2) 一样。subref 是一个常规标量,因此您可以像传递任何其他引用(如数组或哈希的引用)一样传递它,您可以将它们放入数组和哈希中。您还可以通过说 my $subref = sub { ... }(其中 ... 是子程序的主体)来匿名地构造它们。

这提供了几个非常实用的选项。

闭包

闭包是使用函数式编程中使用子程序的主要构建块。闭包是一个记住其词法擦除板的子程序。用英语来说,这意味着如果您对一个使用在它外部定义的 my 变量的子程序引用,它将记住该变量定义时的值,并且能够访问它,即使您在变量的作用域之外使用该子程序。

您在正常代码中看到的闭包主要有两种变体。第一种是命名闭包。

{
    my $counter = 0;
    sub inc_counter { return $counter++ }
}

当您调用 inc_counter() 时,您显然已经超出了 $counter 变量的作用域。然而,它将增加计数器的值并返回该值,就像它在作用域内一样。

如果您不习惯面向对象编程,这是一种处理全局状态的好方法。只需将这个想法扩展到多个变量,并为每个变量提供一个获取器和设置器即可。

第二种是无名闭包。

递归

许多递归函数足够简单,不需要保留任何状态。那些需要保留状态的函数更复杂,尤其是如果您想要能够同时多次调用函数时。这时就出现了匿名子程序。

sub recursionSetup {
    my ($x, $y) = @_;

    my @stack;

    my $_recurse = sub {
        my ($foo, $bar) = @_;

        # Do stuff here with $x, $y, and @stack;
    };
    my $val = $_recurse->( $x, $y );

    return $val;
}

内部子程序

子程序定义在 Perl 中是全局的。这意味着 Perl 没有内部子程序。

sub foo {
    sub bar {
    }

    # This bar() should only be accessible from within foo(),
    # but it is accessible from everywhere
    bar():
}

再次出现匿名子程序。

sub foo {
    my $bar = sub {
    };

    # This $bar is only accessible from within foo()
    $bar->();
}

调度表

通常,您需要根据一些用户输入调用特定的子程序。最初尝试这样做通常会看起来像这样

if ( $input eq 'foo' ) {
    foo( @params );
}
elsif ( $input eq 'bar' ) {
    bar( @params );
}
else {
    die "Cannot find the subroutine '$input'\n";
}

然后,有些有抱负的人了解到软引用,并尝试这样做

&{ $input }( @params );

这是不安全的,因为您不知道 $input 将包含什么。您不能保证关于它的任何事,即使有 taint 和所有这些装饰。使用调度表会更安全。

my %dispatch = (
    foo => sub { ... },
    bar => \&bar,
);

if ( exists $dispatch{ $input } ) {
    $dispatch{ $input }->( @params );
}
else {
    die "Cannot find the subroutine '$input'\n";
}

添加和删除可用子程序比 if-elsif-else 情况简单,而且这比软引用情况更安全。这是两者的最佳结合。

子程序工厂

通常,你会有很多看起来非常相似的子程序。你可能有一些访问器,它们访问的对象属性不同。或者,你可能有一组只在使用常数上有所不同的数学函数。

sub make_multiplier { 
    my ($multiplier) = @_;

    return sub {
        my ($value) = @_;
        return $value * $multiplier;
    };
}

my $times_two  = make_multiplier( 2 );
my $times_four = make_multiplier( 4 );

print $times_two->( 6 ), "\n";
print $times_four->( 3 ), "\n";

----

12
12

尝试运行这段代码,看看它做了什么。你应该能看到下划线下的值。

结论

子程序可能是程序员工具箱中最强大的工具。它们提供了重用代码段、验证这些段和创建以新颖方式解决问题的算法的能力。它们将减少你编程所需的时间,同时让你在这段时间里做更多的事情。它们将减少你代码中的错误数量,并允许其他人与你合作时感到安全。它们确实是编程的超级工具。

标签

反馈

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