有助于编写更佳代码的调试技术和工具

修复代码中的 bug 和错误这项工作非常耗时,有时甚至令人沮丧。 了解如何有效地调试需要时间。 Visual Studio 等功能强大的 IDE 可让你的工作更加轻松。 IDE 有助于更快地修复错误并调试代码,还可帮助你编写效果更好、bug 更少的代码。 本文从整体上介绍“修复 bug”的过程,让你知道何时使用代码分析器、何时使用调试程序、如何修复异常以及如何有目的地编写代码。 如果你已经知道需要使用调试程序,请参阅初探调试程序

本文介绍如何使用 IDE 提高编码会话的效率。 其中涉及几个任务,例如:

  • 使用 IDE 的代码分析器准备要调试的代码

  • 如何修复异常(运行时错误)

  • 如何有目的地编码来最大程度减少 bug(使用断言)

  • 何时使用调试器

为了演示这些任务,我们将展示尝试调试应用时可能最常遇到的一些错误和 bug 类型。 尽管示例代码为 C#,但概念性信息通常适用于 C++、Visual Basic、JavaScript 和 Visual Studio 支持的其他语言(有标注处除外)。 屏幕截图为 C#。

创建一个包含 bug 和错误的示例应用

下面的代码包含一些 bug,可使用 Visual Studio IDE 进行修复。 此应用程序是一个简单应用,它模拟这样一个过程:从某个操作获取 JSON 数据,将该数据反序列化为对象,并使用新数据更新简单列表。

若要创建应用,必须安装 Visual Studio 和 .NET 桌面开发工作负载。

  • 如果尚未安装 Visual Studio,请转到 Visual Studio 下载页免费安装。

  • 如果需要安装工作负载但已安装 Visual Studio,请选择“工具”>“获取工具和功能”。 Visual Studio 安装程序启动。 选择“.NET 桌面开发”工作负载,然后选择“修改” 。

请按以下步骤创建该应用程序:

  1. 打开 Visual Studio。 在“开始”窗口中,选择“创建新项目”。

  2. 在搜索框中,输入 console,然后输入 .NET 的 Console App 选项之一。

  3. 选择下一步

  4. 输入项目名称,如 Console_Parse_JSON,然后选择适用的“下一步”“创建”

    选择建议的目标框架或 .NET 8,然后选择“创建”。

    如果没有看到 .NET 的“控制台应用”项目模板,请转到“工具”>“获取工具和功能”,这会打开 Visual Studio 安装程序。 选择“.NET 桌面开发”工作负载,然后选择“修改” 。

    Visual Studio 创建控制台项目,该项目显示在右窗格的“解决方案资源管理器”中。

项目准备就绪后,将项目的 Program.cs 文件中的默认代码替换为以下示例代码:

using System;
using System.Collections.Generic;
using System.Runtime.Serialization.Json;
using System.Runtime.Serialization;
using System.IO;

namespace Console_Parse_JSON
{
    class Program
    {
        static void Main(string[] args)
        {
            var localDB = LoadRecords();
            string data = GetJsonData();

            User[] users = ReadToObject(data);

            UpdateRecords(localDB, users);

            for (int i = 0; i < users.Length; i++)
            {
                List<User> result = localDB.FindAll(delegate (User u) {
                    return u.lastname == users[i].lastname;
                    });
                foreach (var item in result)
                {
                    Console.WriteLine($"Matching Record, got name={item.firstname}, lastname={item.lastname}, age={item.totalpoints}");
                }
            }

            Console.ReadKey();
        }

        // Deserialize a JSON stream to a User object.
        public static User[] ReadToObject(string json)
        {
            User deserializedUser = new User();
            User[] users = { };
            MemoryStream ms = new MemoryStream(Encoding.UTF8.GetBytes(json));
            DataContractJsonSerializer ser = new DataContractJsonSerializer(users.GetType());

            users = ser.ReadObject(ms) as User[];

            ms.Close();
            return users;
        }

        // Simulated operation that returns JSON data.
        public static string GetJsonData()
        {
            string str = "[{ \"points\":4o,\"firstname\":\"Fred\",\"lastname\":\"Smith\"},{\"lastName\":\"Jackson\"}]";
            return str;
        }

        public static List<User> LoadRecords()
        {
            var db = new List<User> { };
            User user1 = new User();
            user1.firstname = "Joe";
            user1.lastname = "Smith";
            user1.totalpoints = 41;

            db.Add(user1);

            User user2 = new User();
            user2.firstname = "Pete";
            user2.lastname = "Peterson";
            user2.totalpoints = 30;

            db.Add(user2);

            return db;
        }
        public static void UpdateRecords(List<User> db, User[] users)
        {
            bool existingUser = false;

            for (int i = 0; i < users.Length; i++)
            {
                foreach (var item in db)
                {
                    if (item.lastname == users[i].lastname && item.firstname == users[i].firstname)
                    {
                        existingUser = true;
                        item.totalpoints += users[i].points;

                    }
                }
                if (existingUser == false)
                {
                    User user = new User();
                    user.firstname = users[i].firstname;
                    user.lastname = users[i].lastname;
                    user.totalpoints = users[i].points;

                    db.Add(user);
                }
            }
        }
    }

    [DataContract]
    internal class User
    {
        [DataMember]
        internal string firstname;

        [DataMember]
        internal string lastname;

        [DataMember]
        // internal double points;
        internal string points;

        [DataMember]
        internal int totalpoints;
    }
}

找到红色和绿色波浪线!

在尝试启动示例应用并运行调试器之前,请检查代码编辑器中的代码,查找红色和绿色波浪线。 它们表示由 IDE 的代码分析器识别的错误和警告。 红色波浪线表示编译时错误,必须先修复这些错误,然后才能运行代码。 绿色波浪线表示警告。 虽然不修复警告通常也可以运行应用,但它们可能是导致 bug 的源头,对它们进行核查常常可以节省不少时间和麻烦。 如果你偏爱列表视图,也可以在“错误列表”窗口中显示这些警告和错误 。

在示例应用中,可以看到几条红色波浪线,表示需要修复问题,还有一条绿色波浪线,表示需要调查。 下面是第一个错误。

用红色波浪线标示的错误

若要修复此错误,你可以查看 IDE 的另一项功能,该功能由灯泡图标表示。

查看灯泡图标!

第一条红色波浪线表示了一个编译时错误。 将鼠标悬停在它上面,可以看到消息 The name `Encoding` does not exist in the current context

请注意,此错误会在左下方显示一个灯泡图标。 灯泡图标 螺丝刀图标 与螺丝刀图标 灯泡图标 一起表示有助于修复或重构内联代码的快速操作。 灯泡表示应修复的问题 。 螺丝刀表示可以选择修复的问题。 单击左侧的“使用 System.Text”,使用第一个建议的修复来解决此错误 。

使用灯泡修复代码

选择此项时,Visual Studio 会将 using System.Text 语句添加到 Program.cs 文件的顶部,此时红色波浪线将消失。 (如果不确定建议的修补程序应用的更改,请在应用修复前选择右侧的预览更改链接。)

上述错误很常见,通常可以通过在代码中添加新的 using 语句来解决。 有几个与此类似的常见错误,例如The type or namespace "Name" cannot be found.这类错误可能表示缺少程序集引用(右键单击项目,选择“添加”>“引用”)、名称拼写错误,或需添加缺失的库(对于 C#,可右键单击项目并选择“管理 NuGet 包”以进行添加)。

修复剩余错误和警告

在此代码中,还有几条需要检查的波浪线。 在这里,可以看到一个常见的类型转换错误。 将鼠标悬停在波浪线上时,可以看到代码尝试将字符串转换为整数,但不支持这样做,除非添加显式代码来进行转换。

类型转换错误

由于代码分析器不能估计出你的意图,因此目前没有灯泡可帮助你解决这一问题。 若要修复此错误,你需要了解代码的意图。 在此示例中,不难看出 points 应为数值(整数),因为你是要将 points 添加到 totalpoints

若要修复此错误,请将 User 类的 points 成员从:

[DataMember]
internal string points;

更改为:

[DataMember]
internal int points;

代码编辑器中的红色波浪线消失。

接下来,将鼠标悬停在 points 数据成员的声明中的绿色波浪线上。 代码分析器将显示未对该变量进行赋值。

未赋值变量的警告消息

通常,这表示需要修复的问题。 但是,在示例应用中,实际上是在反序列化过程中将数据存储在 points 变量中,然后将该值添加到 totalpoints 数据成员。 在此示例中,你知道代码的意图,因此可以放心地忽略该警告。 但是,如果想要消除此警告,可以将以下代码:

item.totalpoints = users[i].points;

替换为以下内容:

item.points = users[i].points;
item.totalpoints += users[i].points;

绿色波浪线消失。

修复异常

如果已修复所有红色波浪线表示的问题,并解决(或者至少核查了)所有绿色波浪线表示的问题,就可以启动调试器并运行应用了。

按 F5(“调试”>“开始调试”)或调试工具栏中的“开始调试”按钮(开始调试)。

此时,示例应用引发 SerializationException 异常(运行时错误)。 这表示,应用无法正常处理其尝试序列化的数据。 因为是在调试模式下启动的应用(附加调试器),因此调试器的异常帮助程序会直接转到引发异常的代码,并提供有用的错误消息。

发生 SerializationException

错误消息表明 4o 值无法分析为整数。 因此,在此示例中,你知道数据有缺陷:4o 应为 40。 但是,如果实际情况下数据不由你控制(假设你从 Web 服务中获取数据),该怎么做呢? 如何解决此问题?

遇到异常时,你需要提出(并解答)几个问题:

  • 此异常只是你可以修复的 bug 吗? 或者,

  • 你的用户可能遇到此异常吗?

如果是前者,修复该 bug 即可。 (在示例应用中,需要修复错误的数据。)如果是后者,则可能需要使用 try/catch 块处理代码中的异常(我们将在下一节中介绍其他可用的策略)。 在示例应用中,将以下代码:

users = ser.ReadObject(ms) as User[];

替换为此代码:

try
{
    users = ser.ReadObject(ms) as User[];
}
catch (SerializationException)
{
    Console.WriteLine("Give user some info or instructions, if necessary");
    // Take appropriate action for your app
}

try/catch 块会降低性能,因此尽量只在必要情况下才使用它们,即下面两种情况:(a) 它们可能出现在应用的发行版中;(b) 方法文档指示你检查该异常(假定文档已经完善!)。 在许多情况下,你都可以适当地处理异常,不让用户察觉。

下面是有关异常处理的几个重要提示:

  • 避免使用空 catch 块,例如 catch (Exception) {},它不会采取适当的操作来显示或处理错误。 空的或无信息的 catch 块可能隐藏异常,它不能使代码调试变简单,反而会使它变得更难。

  • 在引发异常的特定函数(示例应用中为 ReadObject)周围使用 try/catch 块。 如果在更大的代码块中使用它,则最终将隐藏错误的位置。 例如,不要在对父函数 ReadToObject 的调用周围使用 try/catch 块(如此处所示),否则你将不知道异常发生的确切位置。

    // Don't do this
    try
    {
        User[] users = ReadToObject(data);
    }
    catch (SerializationException)
    {
    }
    
  • 对于在应用中添加的你不熟悉的功能,尤其是与外部数据交互的功能(例如 Web 请求),请参阅文档以查看该功能可能引发哪些异常。 对于正确的错误处理和应用调试来说,这可能是至关重要的信息。

对于示例应用,通过将 4o 更改为 40 来修复 GetJsonData 方法中的 SerializationException

提示

如果有 Copilot,则可以在调试异常时获得 AI 帮助。 只需查找“询问 Copilot”“询问 Copilot”按钮的屏幕截图。按钮即可。 有关详细信息,请参阅使用 Copilot 进行调试

使用断言阐明代码意图

在调试工具栏中选择“重启”重启应用按钮 (Ctrl + Shift + F5)。 这样可以以更少的步骤重启应用。 控制台窗口中会显示以下输出。

输出中的 null 值

你可以看到此输出中的内容不正确。 第三条记录的 namelastname 值为空!

下面介绍一种有用但通常未被充分利用的编码做法,即在函数中使用 assert 语句。 添加以下代码,可包含运行时检查以确保 firstnamelastname 不为 null。 将 UpdateRecords 方法中的以下代码:

if (existingUser == false)
{
    User user = new User();
    user.firstname = users[i].firstname;
    user.lastname = users[i].lastname;

替换为以下内容:

// Also, add a using statement for System.Diagnostics at the start of the file.
Debug.Assert(users[i].firstname != null);
Debug.Assert(users[i].lastname != null);
if (existingUser == false)
{
    User user = new User();
    user.firstname = users[i].firstname;
    user.lastname = users[i].lastname;

在开发过程中向函数添加类似的 assert 语句,可以帮助指定代码的意图。 在前面的示例中,我们指定以下项:

  • 需要有效的字符串来表示名字
  • 需要有效的字符串来表示姓氏

以这种方式指定意图,可以强制实施要求。 这是一种简单便捷的方法,可用于在开发期间显示 bug。 (还可以使用 assert 语句作为单元测试中的主要元素。)

在调试工具栏中选择“重启”重启应用按钮 (Ctrl + Shift + F5)。

注意

assert 代码仅在调试版本中有效。

重新启动时,调试器会在 assert 语句上暂停,因为表达式 users[i].firstname != null 的计算结果为 false 而不是 true

断言解析为 false

assert 错误表明存在需要核查的问题。 assert 可以涵盖许多不一定会出现异常的情况。 在此示例中,用户不会看到异常,会将 null 值添加为记录列表中的 firstname。 这种情况可能会导致以后出现问题(例如在控制台输出中所看到的),并且可能使调试难度增大。

注意

null 值调用方法时,将生成 NullReferenceException。 通常情况下,应避免用 try/catch 块调试一般异常,即与特定库函数无关的异常。 任何对象都可能引发 NullReferenceException。 如果不确定,请查看库函数的文档。

在调试过程中,最好保留特定的 assert 语句,直到你确定需要将其替换为实际的代码修复。 假设你确定用户在应用的发布版本中可能遇到某个异常。 在这种情况下,必须重构代码,确保你的应用不会引发严重异常或导致其他错误。 那么,为了修复此代码,请将以下代码:

if (existingUser == false)
{
    User user = new User();

替换为此代码:

if (existingUser == false && users[i].firstname != null && users[i].lastname != null)
{
    User user = new User();

使用此代码,可以满足代码要求,确保未将 firstnamelastname 值为 null 的记录添加到数据中。

在此示例中,我们在一个循环中添加了两个 assert 语句。 通常,在使用 assert 时,最好在函数或方法的入口点(开始处)添加 assert 语句。 你现在看到的是示例应用中的 UpdateRecords 方法。 在此方法中,如果任意一方法参数为 null,就必定会出现问题,因此,请在函数入口点处使用 assert 语句检查这两个参数。

public static void UpdateRecords(List<User> db, User[] users)
{
    Debug.Assert(db != null);
    Debug.Assert(users != null);

对于上述语句,你的意图是在更新任何数据之前加载现有数据 (db) 并检索新数据 (users)。

可以将 assert 用于可解析为 truefalse 的任何类型的表达式。 例如,可以添加类似如下的 assert 语句。

Debug.Assert(users[0].points > 0);

如果要指定以下意图,则上述代码将很有用:需要具有大于零 (0) 的新分数值,才更新用户的记录。

在调试器中检查代码

好了,既然你已修复示例应用的所有关键错误和异常,可以继续处理其他重要内容了!

我们展示了调试器的异常帮助程序,但调试器工具的强大功能远不止于此,你还可以利用它执行其他操作,例如单步执行代码并检查其变量。 这些更强大的功能适用于多种情况,特别是以下几种方案:

  • 你正在尝试隔离代码中的运行时 bug,但无法使用前面讨论过的方法和工具来执行此操作。

  • 你想验证代码,即在代码运行时对其进行观察,以确保其行为符合预期。

    在代码运行时对其进行监视具有指导意义。 通过这种方式,你可以更深入地了解代码,并且常可在出现明显征兆之前识别 bug。

若要了解如何使用调试器的基本功能,请参阅零基础调试

修复性能问题

另一种类型的 bug 包括会导致应用程序运行缓慢或使用过多内存的低效代码。 优化性能通常在应用开发的后期进行。 但是,你可能会提前遇到性能问题(例如发现应用的某些部分运行缓慢),这时就可能需要尽早通过分析工具来测试应用。 有关分析工具(例如 CPU 使用情况工具和内存分析器)的详细信息,请参阅首先查看分析工具

本文介绍了如何避免和修复代码中的许多常见错误以及何时使用调试器。 接下来,可以详细了解如何使用 Visual Studio 调试器修复 bug。