Skip to main content

调试

软件开发领域的一句谚语是:“Software has bugs”,软件不可能是完美的。在计算机编程和软件开发领域,“bug”指的是程序代码中的错误或缺陷,这些错误可能导致程序运行异常、崩溃或者产生不正确的结果。bug 的存在可能会影响软件的功能、性能或其他期望的行为。尤其对于新手来说,写出来的程序可能存在各种各样的问题,比如语法错误、逻辑错误、数据类型错误等等。通常,修改程序中的错误并不难,难的是如何定位问题,找出出错的具体地方。调试,就是在程序中找到问题的这一过程。

首先,如何知道程序中有问题呢?一般就是程序的行为与预期不符。比如,给一个输入值,但程序的输出却不是我们期望的结果,或者程序显示了一堆错误信息,又或者程序根本没有输出,程序反应太慢,自己崩溃了等等。

查看程序的错误信息

如果程序出现了严重错误,Python 通常会抛出一个异常并提供一个错误消息和一个堆栈跟踪(traceback)。首先,错误消息会告诉我们出了什么问题,比如,ValueError、 IndexError。堆栈跟踪会显示导致错误的代码行的顺序。最底部一行显示的是错误的直接原因,而上面的行则显示了函数的调用关系。根据这两样信息,基本上就可以直接知道出错的原因和地点了。

如果还是不能确信问题是什么,可以尝试给程序一些不同的输入,或改动几处可疑代码,同时多运行程序几次,验证问题的原因以及修改是否有效。除此以外,下面将要详细介绍的几种方法,也都是调试程序,修改 bug 的非常有效的手段。

print()

如果程序本身没有提供有用的出错信息,可以考虑一个很土,但非常有效的办法:把程序运行中的一些关键数据和状态打印出来。

使用这种调试方法,首先,在可能存在问题的代码区域周围,尤其是在条件语句、循环语句或函数调用之前,插入一些 print 函数,把相关变量打印出来。可以在代码的多个位置都放置上 print 函数,这样以查看代码的执行顺序和流程。如果,在某一步代码之后,打印出来的数据与预期不符合了,很可能就是这里出现了问题。

当使用多个 print 语句时,可以考虑给它们添加标签或注释,这样就可以轻松识别每个输出来自哪个 print 函数。比如:

print("[调试专用] 开始计算", key_value)
# 这里是被监视的计算代码
print("[调试专用] 计算结束", key_value)

一旦问题得到解决,要记得删除或注释掉 print 函数,以保持代码的整洁。

尽管现代开发环境提供了高级的调试工具,但 print 函数调试方法在许多情况下仍然是 Pythora 星球居民的首选方法,尤其是对于简单的程序和小型项目。

日志记录

print 函数虽然好用,但是有一些场合下却无法使用,比如,程序有可能运行在没有显示器的设备上(网络设备,嵌入式设备等);又或者打印的数据太多,频幕上的数据还没看清楚就一闪而过了。在这种情况下,我们可以考虑记录日志来进行调试。

所谓日志,就是把需要查看的数据全都记录到文件中,这样可以记录更多的数据,用于事后做详细分析。

使用日志进行调试是一种非常有效的方法,特别是对于大型的、生产环境的、或多线程、异步的应用程序。日志提供了一种持续记录程序执行情况的方法,这对于后续分析和故障排查是非常有价值的。以下是如何使用日志来调试程序的步骤:

导入日志模块:

在 Python 中,日志相关的操作在其内置的 logging 模块中。使用日志前,需要在程序里导入该模块:

import logging

配置日志

在程序开始时通过配置日志,可以选择日志的格式、保存位置等,方便将来阅读。

logging.basicConfig(level=logging.DEBUG,
format='%(asctime)s - %(levelname)s - %(message)s',
filename='app.log', filemode='w')

在上面的程序中:

  • logging.basicConfig() 是 logging 模块提供的用于配置日志的方法。
  • level 参数设置了日志的级别,见下文详细说明。
  • format='%(asctime)s - %(levelname)s - %(message)s' 定义了日志输出的格式。其中:
    • %(asctime)s 会被日志记录时间替代。
    • %(levelname)s 会被日志的级别(如“DEBUG”、“INFO”等)替代。
    • %(message)s 会被实际的日志消息替代。
  • filename='app.log' 表示将日志消息写入名为 app.log 的文件中,而不是默认的控制台或终端输出。
  • filemode='w' 指定了文件的模式。'w'意味着如果 app.log 文件已经存在,它会被重写(即先清空然后写入新日志)。如果希望在已存在的日志文件后追加内容,应该使用 'a' 作为 filemode 的值。

根据以上的设定,在程序运行后,我们会看到日志文件 app.log 中有类似一下文字的内容:

2023-09-22 15:37:14,528 - DEBUG - This is a debug message
2023-09-22 15:37:15,530 - INFO - This is an info message

选择合适的日志级别

在查看和分析日志的时候,我们很可能需要根据所记录内容的重要与否,进行不同的处理,因此,在记录日志是,最好为每个消息都指定一个适当的级别。日志文件中信息的级别有以下几种:

  • DEBUG: 详细信息,通常仅在诊断问题时有用。
  • INFO: 确认程序按预期运行。
  • WARNING: 表示有些意想不到的事情发生了,或可能在将来发生问题。
  • ERROR: 表示发生了更严重的问题,程序未能执行某个功能。
  • CRITICAL: 严重错误,程序可能无法继续运行。

如果在配置日志时,指定了一几个级别,那么就只有比这个级别严重的信息,才会被记录到日志中。比如指定的级别是 WARNING,那么日志中会有 WARNING、ERROR、CRITICAL 的信息,但 DEBUG 和 INFO 信息则不会被记录下来。

在代码中添加日志记录:

接下来,就是在程序的关键点或可能出错的地方添加日志记录,比如之前使用 print 函数调试的地方,都可以使用日志记录代替:

logging.debug(f"开始计算 {key_value}")
# 这里是被监视的计算代码
logging.debug(f"计算结束 {key_value}")

日志记录函数有一个 exc_info 参数,如果设置为真,就会在日志中记录异常堆栈跟踪信息,比如:

try:
x = 1 / 0
except ZeroDivisionError:
logging.error("出现异常", exc_info=True)

分析日志文件:

当程序发生错误或异常行为时,查看日志文件,找出有关异常、错误或其他重要信息的线索。对于小型或简短的日志文件,简单地打开文件并浏览可能就足够了。搜索特定的关键字、错误码或其他标识以快速定位问题。对于大型程序,日志记录内容特别多的可以考虑使用辅助的文本编辑过滤工具查重内容。对于特别复杂的记录,市面上有很多专业的日志分析工具和软件,如 Logstash、Graylog、Splunk 等。

日志旋转和管理:

在正式产品中,通常不应记录 DEBUG 级别的日志,因为这可能会生成过多的日志数据,消耗存储资源。可以考虑使用 WARNING 级别。

日志文件在长期运行的系统中会逐渐增长,如果不加以管理,也会占用大量的磁盘空间。因此,我们可以考虑对日之进行定期的归档、压缩或删除。在 Linux 系统中,有专门的工具用于日志旋转和管理,比如 logrotate。

断言

断言是 Python 中的一个调试辅助工具。它的核心思想是:开发人员认为某些表达式在程序的特定点一定是 True。如果这些表达式的值为 False,则 Python 会引发一个 AssertionError 异常。

断言是通过 assert 语句来完成的,其后跟一个要被测试的表达式。如果该表达式的结果为 False,则会触发一个异常。比如:

def apply_discount(product_price, discount):
final_price = product_price * (1.0 - discount)
assert 0 <= final_price <= product_price, "无效价格"
return final_price

在上面的例子中,我们期望 final_price 始终介于 0 和 product_price 之间。如果不是这样,断言将失败,并引发一个 AssertionError。

断言能够让我们在程序里,明确的定义代码应有的行为。如果程序中存在问题,其行为与我们预先定义的不同,断言可以立刻捕获它们,从而避免让错误影响到后续程序。断言也可以用来做函数的参数检查,确保函数的调用者提供了正确的参数;也可以用来进行程序配置检查,确保程序的各种预设条件得到满足。

需要注意的是不要过度使用断言,否则可能会使代码难以阅读和维护。断言可以被全局地禁用,在全局优化模式下(使用 -O 命令行开关),所有断言语句都会被全局地删除。因此,我们不能依赖断言来进行关键数据的验证或是为最终产品实现任何关键逻辑。

使用 IDE

许多集成开发环境(IDE)都为 Python 提供了强大的调试功能。例如最常用的 PyCharm, VSCode, Eclipse 与 PyDev 插件等。他们通常提供了友好的用户界面来设置断点、检查变量和堆栈信息、单步执行代码等。在各种 IDE 中调试代码的方法都非常类似,下面以 PyCharm 为例,做简要说明:

设置断点

在代码中,点击想要暂停执行的行数旁边的空白区域。这会在该位置设置一个红色的断点标记。

启动调试器

在顶部菜单栏选择 Run。程序运行致断点出会停下,进入调试状态。

查看变量和表达式

当代码执行到断点时,PyCharm 会暂停并显示调试器窗口。在此窗口,可以查看当前的变量、其值以及任何想要评估的表达式。

控制代码执行

在调试器窗口,会看到一些按钮用于控制代码的执行:

  • 继续执行(或按 F9): 继续执行代码直到下一个断点或程序结束。
  • 逐步执行(或按 F8): 执行下一行代码。
  • 进入(或按 F7): 进入当前行的函数或方法。
  • 跳出(或按 Shift + F8): 完成当前函数或方法的执行,然后暂停。

更改变量的值

在调试器窗口中,可以右击一个变量并选择“Set Value”来更改其值。

条件断点

我们可以让断点只在满足特定条件时触发。右击一个断点,并选择“Edit”。在这里,可以设置条件、日志表达式等。

异常断点

在调试器窗口底部,选择“View Breakpoints”(或按 Ctrl+Shift+F8),这里可以配置当某个异常发生时自动暂停的设置。

查看调用堆栈

在调试窗口的左侧,可以查看当前的调用堆栈,这可以帮助了解代码是如何到达当前位置的。

快速评估表达式

选中代码中的一个表达式,然后右键选择“Evaluate Expression”(或按 Alt+F8),就可以计算表达式的结果。

结束调试

在调试窗口,点击红色方块按钮来停止调试。

使用 Python 内置的 pdb 模块

如果没有使用 IDE,那么可以使用 Python 的内置调试器, pdb,它可以在代码中设置断点、单步执行、查看变量状态等,帮助调试代码。以下是使用 pdb 的详细步骤:

引入 pdb

首先,需要在代码中导入 pdb 模块:

import pdb

设置断点

在想暂停执行的代码位置插入以下语句:

pdb.set_trace()

当 Python 解释器执行到这一行时,它会自动暂停并进入调试模式。

也可以为断点设置条件,使其只在满足某个条件时触发。例如,要在 x 大于 10 时暂停执行,可以这样设置:

if x > 10:
pdb.set_trace()

一种常用的设置是,在程序出现异常的时候,让 pdb 暂停在放生异常的地方,这需要 pdb 的 Post Mortem 功能:

try:
# your code here
except:
import pdb
pdb.post_mortem()

这样当异常发生时,pdb 会自动启动并来到发生异常的位置。

常用的调试命令

在程序暂停状态下,可以输入下面的命令,控制程序流程:

  • h 或 help: 显示帮助菜单。
  • n 或 next: 执行下一行代码,但不进入函数。
  • s 或 step: 执行下一行代码,如果是函数则会进入该函数。
  • c 或 continue: 继续执行,直到遇到下一个断点。
  • q 或 quit: 退出调试器。
  • p \<expression> 或 print \<expression>: 打印表达式的值。
  • l 或 list: 显示当前位置的源代码。
  • ll 或 longlist: 显示当前函数的所有源代码。
  • u 或 up: 在调用堆栈中向上移动。
  • d 或 down: 在调用堆栈中向下移动。
  • b \<line_number>: 在指定行设置一个断点。
  • b: 显示所有断点。
  • cl \<breakpoint_number>: 清除指定的断点。

使用 pdb 命令行工具

如果不希望改变程序,插入断点,也可以直接使用 pdb 命令行工具来启动一个 Python 程序文件。使用这种方法,解释器会在程序的第一行暂停执行。

$ pdb your_script.py

与 IDE 中的调试工具相比,pdb 可能会感觉有点不够直观,但随着使用的增多,就会发现它是一个强大的工具,能帮助我们更好地理解代码的执行流程和状态。