Perl中的面向对象异常处理

本文的主要目标是详细讨论Perl中的异常处理以及如何使用Error.pm实现它。在讨论过程中,我们将触及使用异常处理而非传统错误处理机制的优点、使用eval {}进行异常处理、eval {}的问题以及Fatal.pm提供的功能。但总体而言,我们的重点是使用Error.pm进行异常处理。

什么是异常?

异常可以被定义为在程序执行过程中发生的一种事件,它使程序偏离了正常的执行路径。不同类型的错误都可以引发异常。它们可以从严重的错误,如虚拟内存不足,到简单的编程错误,如尝试从空栈中读取或打开无效文件进行读取。

异常通常携带以下三个重要的信息

  1. 异常类型 - 由异常对象的类决定
  2. 异常发生的位置 - 调用栈跟踪
  3. 上下文信息 - 错误消息和其他状态信息

异常处理器是一段用于优雅地处理异常的代码。在本文的其余部分,我们将交替使用异常处理器和捕获块这两个术语。

通过选择异常来管理错误,应用程序相较于传统的错误处理机制有许多优势。下一节将详细讨论使用异常处理的所有优点。

使用异常处理的优点

面向对象的异常处理允许你将错误处理代码与正常代码分离。因此,代码更简单、更易读,有时也更高效。代码效率更高,因为正常的执行路径不必检查错误。结果,节省了宝贵的CPU周期。

面向对象异常处理的另一个重要优点是能够将错误向上传播到调用栈。这是自动发生的,无需程序员显式检查返回值并将其返回给调用者。此外,向上传播返回值是容易出错的,并且每次跳跃都可能导致丢失重要信息。

大多数情况下,错误发生的位置很少是处理错误的最佳位置。因此,需要将错误向上传播到调用栈。但是,当错误到达可以适当处理的位置时,已经丢失了大量的错误上下文。这是传统错误处理机制(即检查返回值并将其传播到调用者)的常见问题。异常的出现解决了在错误发生处捕获上下文信息并将其传播到可以有效地使用/处理的地方的问题。

例如,如果你有一个名为processFile()的函数,它是应用程序中一系列方法调用的第四个方法。同时,func1()是唯一对processFile()中发生的错误感兴趣的方法。使用传统的错误处理机制,你将执行以下操作,将错误代码向上传播到调用栈,直到错误最终到达func1()

  sub func3
  {
    my $retval = processFile($FILE);
    if (!$retval) { return $retval; }
    else {
      ....
    }
  }

  sub func2
  {
    my $retval = func3();
    if (!$retval) { return $retval; }
    else {
      ....
    }
  }

  sub func1 
  {
    my $retval = func2();
    if (!$retval) { return $retval; }
    else {
      ....
    }
  }

  sub processFile
  {
    my $file = shift;
    if (!open(FH, $file)) { return -1; }
    else {
      # Process the file
      return 1;
    }
  }

使用面向对象异常处理,您只需要将函数调用func2()包裹在try块中,并用适当的异常处理器(catch块)处理该块抛出的异常。带有异常处理的等效代码如下。

   sub func1
   {
     try {
       func2();
     }
     catch IOException with {
       # Exception handling code here
     };  
   }

   sub func2 { func3(); ... }
   sub func3 { processFile($FILE); ... }

   sub processFile
   {
     my $file = shift;
     if (!open(FH, $file)) { 
       throw IOException("Error opening file <$file> - $!");
     }
     else {
       # Process the file
       return 1;
     }     
   }

由于func1()是唯一具有catch块的函数,因此processFile()函数中抛出的异常会一直传播到func1(),在那里会通过catch块适当地处理。这两种错误处理技术在膨胀因子和代码模糊程度上的差异是明显的。

最后,异常可以用来分组相关错误。通过这种方式,您将能够使用单个异常处理器处理相关异常。可以使用异常类的继承层次结构来逻辑地分组异常。因此,异常处理器可以捕获其参数指定的类或其任何子类的异常。

让我们用Perl来实现

Perl的内置异常处理机制

Perl有一个内置的异常处理机制,即eval {}块。它是通过将需要执行的代码包裹在eval块中来实现的,并检查$@变量以确定是否发生了异常。典型的语法如下

  eval {
    ...
  };
  if ($@) {
    errorHandler($@);
  }

在eval块中,如果出现语法错误或运行时错误,或者执行了die语句,则eval返回未定义的值,并将$@设置为错误消息。如果没有错误,则$@保证是一个空字符串。

这有什么问题呢?由于存储在$@中的错误消息是一个简单的标量,检查发生的错误类型是容易出错的。此外,$@没有告诉我们异常发生在哪里。为了克服这些问题,Perl 5.005引入了异常对象。

从Perl 5.005开始,您可以这样做

  eval {
    open(FILE, $file) || 
      die MyFileException->new("Unable to open file - $file");
  };

  if ($@) {
    # now $@ contains the exception object of type MyFileException
    print $@->getErrorMessage();  
    # where getErrorMessage() is a method in MyFileException class
  }

异常类(MyFileException)可以构建具有所需的功能。例如,您可以使用异常类构造函数中的caller()获取调用上下文(通常在MyFileException::new()中)。

还可以像下面这样测试特定的异常类型

  eval {
    ....
  };

  if ($@) {
    if ($@->isa('MyFileException')) {  # Specific exception handler
      ....
    }
    else { # Generic exception handler
      ....
    }
  }

如果异常对象实现了字符串化,通过重载字符串操作,则对象字符串化的版本将在$@在字符串上下文中使用时可用。通过适当构造重载方法,可以调整字符串上下文中$@的值。

  package MyException;

  use overload ('""' => 'stringify');
  ...
  ...
  sub stringify
  {
    my ($self) = @_;
    my $class = ref($self) || $self;

    return "$class Exception: " . $self->errMsg() . " at " . 
                                  $self->lineNo() . " in " . 
                                  $self->file();
    # Assuming that errMsg(), lineNo() & file() are methods 
    # in the exception class
    # to store & return error message, line number and source 
    # file respectively.
  }

当重载字符串运算符““”时,重载方法(在我们的例子中是stringify())应该返回一个表示对象字符串化形式的字符串。stringify()方法可以返回有关异常对象的字符串信息,作为字符串的一部分。

eval的问题

以下是一些使用eval {}构造的问题

  • 相似的外观语法结构可以根据上下文有不同的意义。
  • eval块可以用来构建动态代码片段以及进行异常处理
  • 没有内置的清理处理程序,即finally块
  • 如果需要,需要编写自定义代码来维护堆栈跟踪
  • 从美学上看不吸引人(尽管这非常主观)

Error.pm来救命

Error.pm模块实现了面向对象的异常处理。它模仿了Java和C++等面向对象语言中可用的try/catch/throw语法(仅举几例)。它也不存在使用eval时固有的所有问题。由于它是一个纯Perl模块,它在Perl运行的几乎所有平台上都可以运行。

使用Error.pm

该模块提供了两个接口

  1. 用于异常处理的进程式接口(异常处理构造块)
  2. 其他异常类的基类

该模块导出各种函数以执行异常处理。如果使用use语句中的:try标签,则它们将被导出。

典型的调用方式如下

  use Error qw(:try);

  try {
    some code;
    code that might thrown an exception;
    more code;
    return;
  }
  catch Error with {
    my $ex = shift;   # Get hold of the exception object
    handle the exception;
  }
  finally {
    cleanup code;
  };  # <-- Remember the semicolon

不要忘记在闭合花括号后包含尾随的分号 (;)。对于想了解原因的您:所有这些函数都将代码引用作为它们的第一个参数。例如,在 try 块中,try 后面的代码作为代码引用(匿名函数)传递给函数 try().

try 块

通过将可能抛出异常的语句包含在一个 try 块中来构造异常处理程序。如果在 try 块中发生异常,则它将由与 try 块关联的相应异常处理程序(catch 块)处理。如果没有抛出异常,则 try 将返回块的结果。

语法是:try BLOCK EXCEPTION_HANDLERS

try 块应该至少有一个(或更多)catch 块或一个 finally 块。

catch 块

try 块关联其相关异常处理程序的范畴。您通过在 try 块之后直接提供一个或多个 catch 块来将异常处理程序与 try 块关联。

  try {
    ....
  }
  catch IOException with {
    ....
  }
  catch MathException with {
    ....
  };

语法是:catch CLASS with BLOCK

这使所有满足条件 $ex->isa(CLASS) 的错误都可以通过评估 BLOCK 来处理。

BLOCK 接收两个参数。第一个是正在抛出的异常,第二个是标量引用。如果从 catch 块返回时设置了该标量引用,那么 try 块将继续执行,就像没有异常发生一样。

如果第二个参数引用的标量未设置,并且(在 catch 块内)没有抛出异常,那么当前 try 块将带有 catch 块的结果返回。

为了传播异常,catch 块可以选择通过调用 $ex->throw() 重新抛出异常。

Catch 块的顺序很重要

异常处理程序的顺序很重要。如果你在不同的继承层次结构级别有处理程序,那就更加关键。设计来处理继承层次结构根(Error)最远的异常类型的异常处理程序应首先放在 catch 块列表中。

设计来处理特定类型对象的异常处理程序可能被另一个处理程序抢占,该处理程序的异常类型是该类型的超类。如果该异常类型的处理程序在异常处理程序列表中较早出现,则会发生这种情况。

例如

  try {
    my $result = $self->divide($value, 0);  
    # divide() throws DivideByZeroException
    return $result;  
  }
  catch MathException with {
    my $ex = shift;
    print "Error: Caught MathException occurred\n";
    return;
  }
  catch DivideByZeroException with {
    my $ex = shift;
    print "Error: Caught DivideByZeroException\n";
    return 0;
  };

假设继承层次结构

  MathException is-a Error                 
       [ @MathException::ISA = qw(Error) ]
  DivideByZeroException is-a MathException 
       [ @DivideByZeroException::ISA = qw(MathException) ]

在上面的代码列表中,DivideByZeroException 被第一个 catch 块捕获,而不是第二个。这是因为 DivideByZeroException 是 MathException 的子类。换句话说,$ex->isa(‘MathException’) 返回 true。因此,异常由第一个 catch 块内的代码处理。反转 catch 块的顺序将确保异常被正确的异常处理程序捕获。

finally 块

设置异常处理程序的最后一步是提供一个在将控制权传递到程序的另一部分之前进行清理的机制。这可以通过在 finally 块内封装清理逻辑来实现。finally 块中的代码无论 try 块中发生什么都会执行。finally 块的典型用途是关闭文件或通常释放任何系统资源。

如果没有抛出异常,则不会执行 catch 块中的任何代码。但 finally 块中的代码始终执行。

如果抛出异常,则执行适当的 catch 块中的代码。一旦该代码执行完成,将执行 finally 块。

  try {
    my $file = join('.', '/tmp/tempfile', $$);

    my $fh = new FileHandle($file, 'w');
    throw IOException("Unable to open file - $!") if (!$fh);

    # some code that might throw an exception
    return;
  }
  catch Error with {
    my $ex = shift;
    # Exception handling code
  }
  finally {
    close($fh) if ($fh);    # Close the temporary file
    unlink($file);          # Delete the temporary file
  };

在上面的代码列表中,try 块中创建了一个临时文件,并且该块还包含可能抛出异常的代码。无论 try 块是否成功,都需要关闭和从文件系统中删除该临时文件。这是通过在 finally 块中关闭和删除文件来完成的。

记住,每个 try 块只允许有一个 finally 块。

throw 语句

throw() 创建一个新的“Error”对象并抛出异常。如果存在包围的try块,则该异常将被捕获。否则程序将退出。

throw() 还可以在现有的异常上调用以重新抛出它。下面的代码示例说明了如何重新抛出异常

  try {
    $self->openFile();
    $self->processFile();
    $self->closeFile();
  }
  catch IOException with {
    my $ex = shift;
    if (!$self->raiseException()) {
      warn("IOException occurred - " . $ex->getMessage());
      return;
    }
    else { 
      $ex->throw(); # Re-throwing exception
    }
  };

构建自己的异常类

将$Error::Debug包变量的值设置为true,可以启用捕获堆栈跟踪,稍后可以使用stacktrace()方法检索它。(如果你不熟悉,堆栈跟踪是导致异常的所有按顺序执行的方法列表)。

下面的代码片段创建了MathException、DivideByZero和OverFlowException异常类。后两个是MathException的子类,而MathException本身是从Error.pm派生的。

  package MathException;

  use base qw(Error);
  use overload ('""' => 'stringify');

  sub new
  {
    my $self = shift;
    my $text = "" . shift;
    my @args = ();

    local $Error::Depth = $Error::Depth + 1;
    local $Error::Debug = 1;  # Enables storing of stacktrace

    $self->SUPER::new(-text => $text, @args);
  }
  1;

  package DivideByZeroException;
  use base qw(MathException);
  1;

  package OverFlowException;
  use base qw(MathException);
  1;

等等...

错误模块还有其他特殊的异常处理块,如exceptotherwise。它们在这里没有介绍,因为它们是针对Error.pm的特定功能,你不会在其他OO语言中找到它们。对于感兴趣的人,请参阅Error.pm内嵌入的POD文档。

Fatal.pm

如果你有一些在错误时返回false而在成功时返回true的函数,那么你可以使用Fatal.pm将它们转换为在失败时抛出异常的函数。这既可以用于用户定义的函数,也可以用于内建函数(有一些例外)。

  use Fatal qw(open close);

  eval { open(FH, "invalidfile") }; 
  if ($@) {
    warn("Error opening file: $@\n");
  }

  ....
  ....

  eval { close(FH); }; 
  warn($@) if ($@);

默认情况下,Fatal.pm会捕获对已声明的致命函数的每次使用。

  use Fatal qw(chdir);
  if (chdir("/tmp/tmp/")) {
    ....
  }
  else { 
    # Execution flow never reaches here
  }

如果你幸运地有Perl 5.6或更高版本,那么你可以在导入列表中添加:void来规避这个问题。在该导入列表中命名的所有函数仅在它们在void上下文中调用时(即它们的返回值被忽略时)抛出异常。

通过更改下面的使用语句,我们可以确保当chdir()失败时执行else块中的代码。

  use Fatal qw(:void chdir);

下面的代码示例说明了Fatal.pm与Error.pm的结合使用。

  use Error qw(:try);
  use Fatal qw(:void open);

  try {
    open(FH, $file);
    ....
    openDBConnection($dsn);
    return;
  }
  catch DBConnectionException with {
    my $ex = shift;
    # Database connection failed
  }
  catch Error with {
    my $ex = shift;
    # If the open() fails, then we'll be here  
  };

Perl 6 连接

由于Perl 6的异常处理语法预计将根据Perl 6 RFC 63异常处理语法(https://dev.perl5.cn/rfc/63.pod)详细说明的Error.pm进行建模,也参考RFC 80和RFC 88。对于开发者来说,在他们代码中使用OO异常处理功能,并最终在Perl 6的异常处理语法可用时迁移到它,是有意义的。

结论

以下是一些选择异常处理机制而不是传统错误处理机制的关键原因

  • 错误处理代码可以与正常代码分离
  • 更简单、更易读、更高效的代码
  • 能够将错误向上传播到调用堆栈
  • 能够为异常处理程序持久化上下文信息
  • 错误类型的逻辑分组

所以,停止返回错误代码,开始抛出异常。

享受非凡的时刻!!

标签

反馈

这篇文章有什么问题吗?请在GitHub上打开一个issue或pull request来帮助我们,链接为GitHub