Python中处理异常的正确方法是什么?

30

我在Stack Overflow上搜索了其他帖子,因为我认为这是一个相当常见的问题,但我发现所有其他Python异常问题都不反映我的问题。

我会尽可能具体地说明问题,所以我将给出一个直接的例子。并且请不要针对这个具体问题发布任何解决方法。我并不特别关心如何使用xyz更好地发送电子邮件。我想知道您通常如何处理依赖于彼此的容易出错的语句的异常。

我的问题是,如何优雅地处理异常,即仅在第一步成功后再尝试下一步,以此类推。另一个标准是:必须捕获所有异常,因此代码必须健壮。

以下是一个示例供您参考:

try:
    server = smtplib.SMTP(host) #can throw an exception
except smtplib.socket.gaierror:
    #actually it can throw a lot more, this is just an example
    pass
else: #only if no exception was thrown we may continue
    try:
        server.login(username, password)
    except SMTPAuthenticationError:
        pass # do some stuff here
    finally:
        #we can only run this when the first try...except was successful
        #else this throws an exception itself!
        server.quit() 
    else:
        try:
            # this is already the 3rd nested try...except
            # for such a simple procedure! horrible
            server.sendmail(addr, [to], msg.as_string())
            return True
        except Exception:
            return False
        finally:
            server.quit()

return False

我认为这段代码非常不符合Python的编程规范,而且错误处理代码比实际业务代码还要多三倍,但另一方面,如果有几个语句是彼此依赖的,也就是说statement1是statement2的前提条件等等,那么怎样处理呢?

我还对适当的资源清理很感兴趣,即使是Python自己也可以进行管理。

谢谢,汤姆


1
感谢您编辑dbr,但请不要编辑您自己不确定的内容。我将标准重新编辑为criterion,这确实是单数形式,因为在您编辑的地方复数形式没有意义。 - Tom
抱歉,这个我不太清楚(嗯,我从来没有听说过criteria的单数形式...) - dbr
7个回答

28

不必使用try/except的else块,当出现错误时,你可以直接返回:

def send_message(addr, to, msg):
    ## Connect to host
    try:
        server = smtplib.SMTP(host) #can throw an exception
    except smtplib.socket.gaierror:
        return False

    ## Login
    try:
        server.login(username, password)
    except SMTPAuthenticationError:
        server.quit()
        return False

    ## Send message
    try:
        server.sendmail(addr, [to], msg.as_string())
        return True
    except Exception: # try to avoid catching Exception unless you have too
        return False
    finally:
        server.quit()

这是非常易读且符合Python风格的代码。

另一种实现方式是,不必担心具体的实现细节,而是决定你希望代码看起来如何,例如...

sender = MyMailer("username", "password") # the except SocketError/AuthError could go here
try:
    sender.message("addr..", ["to.."], "message...")
except SocketError:
    print "Couldn't connect to server"
except AuthError:
    print "Invalid username and/or password!"
else:
    print "Message sent!"

然后编写message()方法的代码,捕获您预期的任何错误,并引发自定义错误,在相关位置处理它。您的类可能看起来像这样..

class ConnectionError(Exception): pass
class AuthError(Exception): pass
class SendError(Exception): pass

class MyMailer:
    def __init__(self, host, username, password):
        self.host = host
        self.username = username
        self.password = password

    def connect(self):
        try:
            self.server = smtp.SMTP(self.host)
        except smtplib.socket.gaierror:
            raise ConnectionError("Error connecting to %s" % (self.host))

    def auth(self):
        try:
            self.server.login(self.username, self.password)
        except SMTPAuthenticationError:
            raise AuthError("Invalid username (%s) and/or password" % (self.username))

    def message(self, addr, to, msg):
        try:
            server.sendmail(addr, [to], msg.as_string())
        except smtplib.something.senderror, errormsg:
            raise SendError("Couldn't send message: %s" % (errormsg))
        except smtp.socket.timeout:
            raise ConnectionError("Socket error while sending message")

5
+1 我非常喜欢你是如何解决“库只使用一种异常来处理所有问题”的问题的。 - Tom Leys
1
在你的第一个例子中,send_message()将始终在server.login()之后返回,并且永远不会发送消息。我认为这个语句块不应该有finally关键字。 - mhawke
1
现在问题归结为一个原则问题。你的第一段代码基本上和我的一样,只是你没有像我在“else”树中嵌套异常,这在Python文档中被建议。哪种做法更好?文档指出,应始终优先使用else而不是在try块中添加其他语句。这基本上是与if有关的同样的问题:是在另一个if之前返回更好,还是有条件地嵌套ifs更好。 - Tom
通常我认为你不应该嵌套try/except块,特别是不要超过3层,或者像问题中那样有很多代码 - 也许可以用于顺序尝试导入模块(例如尝试import cElementTree,然后是ElementTree,最后尝试lxml.et.ElementTree)。 - dbr
1
我选择这个作为被接受的答案,因为我认为我的嵌套异常处理不太易读,尽管我认为使用try...except块的else语句是提出的方法。对于你的第二个例子:这是一个很好的处理问题的方式,但通常重新打包被认为是不好的实践。感谢您宝贵的意见dbr :) - Tom
没问题!“但通常重新打包被认为是不好的做法。” 我不认为我的第二段代码是重新打包,它只是利用了类(在Python中,类只是将一堆函数/变量组合在一起的方式)-我并不建议您单独重新分发该类,只需像对待任何其他函数一样即可。 - dbr

12

一般来说,在编写代码时应尽可能少地使用try块,通过抛出的异常类型区分失败条件。例如,以下是我对您发布的代码进行重构后的版本:

try:
    server = smtplib.SMTP(host)
    server.login(username, password) # Only runs if the previous line didn't throw
    server.sendmail(addr, [to], msg.as_string())
    return True
except smtplib.socket.gaierror:
    pass # Couldn't contact the host
except SMTPAuthenticationError:
    pass # Login failed
except SomeSendMailError:
    pass # Couldn't send mail
finally:
    if server:
        server.quit()
return False

在此,我们利用了smtplib.SMTP(),server.login()和server.sendmail()都会抛出不同异常的事实来简化try-catch块的嵌套。 在finally块中,我们明确测试server以避免在nil对象上调用quit()。

如果有需要单独处理的重叠异常情况,则我们也可以使用三个顺序的try-catch块,在异常条件下返回False:

try:
    server = smtplib.SMTP(host)
except smtplib.socket.gaierror:
    return False # Couldn't contact the host

try:
    server.login(username, password)
except SMTPAuthenticationError:
    server.quit()
    return False # Login failed

try:
    server.sendmail(addr, [to], msg.as_string())
except SomeSendMailError:
    server.quit()
    return False # Couldn't send mail

return True

这种方法不太好,因为需要在不止一个地方关闭服务器,但现在我们可以在不维护任何额外的状态的情况下,在不同的位置以不同的方式处理特定的异常类型。


5
重点是,正如上面所述,它们不会抛出单个异常,因此无法轻松地将其展平。如果您的连接在身份验证之前断开,则server.login和server.sendMail可能会抛出相同的异常(“首先连接服务器”)。但是,正如我上面所说的那样,我并不是在寻找解决这个特定问题的方法。我更感兴趣的是一种通用的解决方法。你提出的第二种方法基本上就是我的代码,只是没有“else”。虽然我必须承认,这种写法比较美观 ;) - Tom
1
在 finally 块上要小心 —— 你需要在块之前将 server 设置为 None,以避免意外引用不存在的变量。 - Paul Fisher
@Tom +1,这就是为什么我没有建议这个解决方案的原因。 - Unknown
如果你的程序每行都可能抛出多个不同的异常,并且每个异常都需要单独处理,那么你的代码大部分将是异常处理。这并没有什么不好或者不符合Python风格的地方。只要确保你选择了一种可扩展的方法:如果你有十个不同的代码组,每个组都可以抛出4个不同的异常,那么每个代码组不应该引入新的缩进级别就变得非常重要。这就是为什么要进行“平铺”,也是我对使用else:持谨慎态度的原因,无论文档怎么说。 - David Seiler

3
如果是我,我可能会这样做:

如果是我,我可能会这样做:

try:
    server = smtplib.SMTP(host)
    try:
        server.login(username, password)
        server.sendmail(addr, [to], str(msg))
    finally:
        server.quit()
except:
    debug("sendmail", traceback.format_exc().splitlines()[-1])
    return True

所有错误都被捕获和调试,成功时返回值为True,如果建立了初始连接,则服务器连接将被适当清理。

这对我来说看起来很直观,这也可能是在Java中的样子,因为你没有那里的“else”语句的奢侈品。我记得在Python2.5之前也没有这个。它被引入文档中是为了避免大型try块,但仍然保持分组良好和明显,因此所有属于一起的代码仍将位于同一个try...except...else块中,而不会使try部分失去比例。由于Python程序员非常喜欢他们的样式指南和PEP,我认为这将是正确的方法。 - Tom

1

只使用一个try块是最好的方法。这正是它们的设计目的:仅在前一个语句未引发异常时执行下一个语句。至于资源清理,您可以检查资源是否需要清理(例如myfile.is_open(),...)。这确实增加了一些额外的条件,但它们只会在异常情况下执行。为了处理同一异常可能因不同原因而引发的情况,您应该能够从异常中检索原因。

我建议像这样编写代码:

server = None
try:
    server = smtplib.SMTP(host) #can throw an exception
    server.login(username, password)
    server.sendmail(addr, [to], msg.as_string())
    server.quit()
    return True
except smtplib.socket.gaierror:
    pass # do some stuff here
except SMTPAuthenticationError:
    pass # do some stuff here
except Exception, msg:
    # Exception can have several reasons
    if msg=='xxx':
        pass # do some stuff here
    elif:
        pass # do some other stuff here

if server:
    server.quit()

return False

错误处理代码超过业务代码并不罕见。正确的错误处理可能很复杂。 但为了增加可维护性,将业务代码与错误处理代码分开会有所帮助。


我不同意,因为正如我在上面多次声明的那样,错误信息会很模糊,例如,两个不同的函数调用,比如登录和发送邮件,可能会抛出相同的异常。如果你想向用户或日志输出“登录()失败了,因为xyz”或“发送邮件()失败了,因为xyz”,这是不可能的,因为这两个调用都可能导致相同的异常。我希望对出错的详细处理以便记录。 - Tom
异常应该能够提供其详细信息。例如,您可以使用“except gaierror,(code,message):”而不是简单的“except gaierror:”。然后,您就有了错误代码和错误消息,并且可以将它们用于详细的错误处理,例如,如果code == 11001:print“未知主机名:”,message。 - Ralph
我觉得你没有完全理解我的意思。请尝试以下步骤:创建一个SMTP对象,然后尝试在未连接情况下调用smtp.login() 和 smtp.sendmail() 函数,你会发现它们抛出的异常是100%相同的,无论是通过msg还是errno都无法区分。 - Tom
我明白了。在这种情况下,最好添加一个包装器函数来转换异常(正如其他人所建议的那样)。 - Ralph

1
我会尝试这样做:
class Mailer():

    def send_message(self):
        exception = None
        for method in [self.connect, 
                       self.authenticate, 
                       self.send, 
                       self.quit]:
            try:
                if not method(): break
            except Exception, ex:
                exception = ex
                break

        if method == quit and exception == None:
            return True

        if exception:
            self.handle_exception(method, exception)
        else:
            self.handle_failure(method)

    def connect(self):
        return True

    def authenticate(self):
        return True

    def send(self):
        return True

    def quit(self):
        return True

    def handle_exception(self, method, exception):
        print "{name} ({msg}) in {method}.".format(
           name=exception.__class__.__name__, 
           msg=exception,
           method=method.__name__)

    def handle_failure(self, method):
        print "Failure in {0}.".format(method.__name__)

所有的方法(包括send_message)都遵循相同的协议:如果成功,则返回True;除非它们实际上处理异常,否则它们不会捕获异常。该协议还使得处理一个方法需要指示它失败而不引发异常的情况成为可能。(如果您的方法唯一失败的方式是引发异常,那么这简化了协议。如果您需要在方法之外处理大量非异常失败状态,那么您可能有一个尚未解决的设计问题。)

这种方法的缺点是所有的方法都必须使用相同的参数。我选择了不使用参数,因为我期望我已经存根的方法最终会操作类成员。

然而,这种方法的好处也是相当多的。首先,您可以添加数十种方法到进程中,而send_message并不会变得更加复杂。

您甚至可以疯狂地做类似于以下的事情:

def handle_exception(self, method, exception):
    custom_handler_name = "handle_{0}_in_{1}".format(\
                                             exception.__class__.__name__,
                                             method.__name__)
    try:
        custom_handler = self.__dict__[custom_handler_name]
    except KeyError:
        print "{name} ({msg}) in {method}.".format(
           name=exception.__class__.__name__, 
           msg=exception,
           method=method.__name__)
        return
    custom_handler()

def handle_AuthenticationError_in_authenticate(self):
   print "Your login credentials are questionable."

......虽然此时,我可能会对自己说,“自己啊,你在没有创建Command类的情况下非常努力地使用Command模式。也许现在是时候了。”


0

我喜欢David的答案,但如果你卡在服务器异常上,也可以检查服务器是否为None或状态。我稍微简化了方法,但它看起来仍然不太符合Python风格,但底部的逻辑更易读。

server = None 

def server_obtained(host):
    try:
        server = smtplib.SMTP(host) #can throw an exception
        return True
    except smtplib.socket.gaierror:
        #actually it can throw a lot more, this is just an example
        return False

def server_login(username, password):
    loggedin = False
    try:
        server.login(username, password)
        loggedin = True
    except SMTPAuthenticationError:
        pass # do some stuff here
    finally:
        #we can only run this when the first try...except was successful
        #else this throws an exception itself!
        if(server is not None):
            server.quit()
    return loggedin

def send_mail(addr, to, msg):
    sent = False
     try:
        server.sendmail(addr, to, msg)
        sent = True
    except Exception:
        return False
    finally:
        server.quit()
    return sent

def do_msg_send():
    if(server_obtained(host)):
        if(server_login(username, password)):
            if(send_mail(addr, [to], msg.as_string())):
                return True
    return False 

在 server_login 和 send_mail 函数中,你可以避免使用本地变量,因为无论你在 try 或 except 块中使用 "return",最终都会执行 :) 你可以在 try 块中简单地返回 True,在 except 块中返回 False,而不是保存状态在一个本地变量中。 - Tom

0
为什么不使用一个大的try块呢?这样,如果捕获到任何异常,你就会一直走到except。只要不同步骤的所有异常都不同,你就可以知道是哪个部分触发了异常。

因为一个大的try块不能让你有机会处理资源。例如:语句1工作正常,并打开了一个文件,但是语句2抛出了异常。你不能在一个单独的finally块中处理它,因为你永远不知道资源是否实际分配了。此外,某些步骤可能会触发相同的异常,因此无法确定后来出了什么问题,也无法打印错误,因为你不确定哪个语句实际上失败了。 - Tom
遗憾的是,我不能依赖于所有操作都定义了__enter__和__exit__方法,所以with可能并不总是有效。 - Tom
但是你总是可以构建自己的包装器——它们足够轻量级,特别是如果你使用装饰器,而且如果它们能使你的代码更易读,那么它们是值得的。 - Jacob B

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接