C# 13 和 .NET 9 全知道 : 3控制流程、转换类型和处理异常 (4)
从二进制对象转换为字符串
当您有一个二进制对象,如图像或视频,您想要存储或传输时,有时您不想发送原始位,因为您不知道这些位可能会如何被误解,例如,被传输它们的网络协议或读取存储的二进制对象的另一个操作系统。
最安全的做法是将二进制对象转换为安全字符的 string 。程序员称之为 Base64 编码。Base64 是一种编码方案,它使用特定的 64 个字符将任意字节转换为文本。它广泛用于数据传输,并通过各种方法长期得到支持。
Convert 类型有一对方法, ToBase64String 和 FromBase64String ,可以为您执行此转换。让我们看看它们的实际应用:
- 输入语句以创建一个随机填充字节值的字节数组,将每个字节格式化后写入控制台,然后将相同的字节转换为 Base64 并写入控制台,如以下代码所示:
// Allocate an array of 128 bytes.
byte[] binaryObject = new byte[128];
// Populate the array with random bytes.
Random.Shared.NextBytes(binaryObject);
WriteLine("Binary Object as bytes:");
for (int index = 0; index < binaryObject.Length; index++)
{
Write(#34;{binaryObject[index]:X2} ");
}
WriteLine();
// Convert the array to Base64 string and output as text.
string encoded = ToBase64String(binaryObject);
WriteLine(#34;Binary Object as Base64: {encoded}");
默认情况下, int 值将假定为十进制表示,即 Base10。您可以使用格式代码,例如 :X2 ,以十六进制表示格式化该值。
- 运行代码并查看结果,如下所示的输出:
Binary Object as bytes:
EB 53 8B 11 9D 83 E6 4D 45 85 F4 68 F8 18 55 E5 B8 33 C9 B6 F4 00 10 7F CB 59 23 7B 26 18 16 30 00 23 E6 8F A9 10 B0 A9 E6 EC 54 FB 4D 33 E1 68 50 46 C4 1D 5F B1 57 A1 DB D0 60 34 D2 16 93 39 3E FA 0B 08 08 E9 96 5D 64 CF E5 CD C5 64 33 DD 48 4F E8 B0 B4 19 51 CA 03 6F F4 18 E3 E5 C7 0C 11 C7 93 BE 03 35 44 D1 6F AA B0 2F A9 CE D5 03 A8 00 AC 28 8F A5 12 8B 2E BE 40 C4 31 A8 A4 1A
Binary Object as Base64: 61OLEZ2D5k1FhfRo+BhV5bgzybb0ABB/y1kjeyYYFjAAI+aPqRCwqebsVPtNM+FoUEbEHV+xV6Hb0GA00haTOT76CwgI6ZZdZM/lzcVkM91IT+iwtBlRygNv9Bjj5ccMEceTvgM1RNFvqrAvqc7VA6gArCiPpRKLLr5AxDGopBo=
用于 URL 的 Base64
Base64 很有用,但它使用的一些字符,如 + 和 / ,在某些用途中是有问题的,例如在 URL 中的查询字符串,这些字符具有特殊含义。
为了解决这个问题,创建了 Base64Url 方案。它类似于 Base64,但使用了一组略有不同的字符,使其适合用于 URL 等上下文。
更多信息:您可以通过以下链接了解有关 Base64Url 方案的更多信息:https://base64.guru/standards/base64url。
.NET 9 引入了新的 Base64Url 类,该类提供了一系列优化的方法,用于使用 Base64Url 方案对数据进行编码和解码。例如,您可以将一些任意字节转换为 Base64Url,如以下代码所示:
ReadOnlySpan<byte> bytes = ...;
string encoded = Base64Url.EncodeToString(bytes);
从字符串解析到数字或日期和时间
第二常见的转换是从字符串到数字或日期和时间值。
ToString 的相反是 Parse 。只有少数类型具有 Parse 方法,包括所有数字类型和 DateTime 。
让我们看看 Parse 的实际应用:
- 在 Program.cs 的顶部,导入用于处理文化的命名空间,如下代码所示:
using System.Globalization; // To use CultureInfo.
在 Program.cs 的底部,添加语句以从字符串中解析整数和日期时间值,然后将结果写入控制台,如以下代码所示:
// Set the current culture to make sure date parsing works.
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-US");
int friends = int.Parse("27");
DateTime birthday = DateTime.Parse("4 June 1980");
WriteLine(#34;I have {friends} friends to invite to my party.");
WriteLine(#34;My birthday is {birthday}.");
WriteLine(#34;My birthday is {birthday:D}.");
运行代码并查看结果,如下所示的输出:
I have 27 friends to invite to my party.
My birthday is 6/4/1980 12:00:00 AM.
My birthday is Wednesday, June 4, 1980.
默认情况下,日期和时间值以短日期和时间格式输出。您可以使用格式代码,例如 D ,仅使用长日期格式输出日期部分。
良好实践:使用标准日期和时间格式说明符,如以下链接所示:https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings#table-of-format-specifiers。
通过使用 TryParse 方法避免解析异常
Parse 方法的一个问题是,如果 string 无法转换,则会出现错误:
- 输入一个语句,尝试将包含字母的字符串解析为整数变量,如以下代码所示:
int count = int.Parse("abc");
运行代码并查看结果,如下所示的输出:
Unhandled Exception: System.FormatException: Input string was not in a correct format.
除了前面的异常消息,您还会看到一个堆栈跟踪。我在本书中没有包含堆栈跟踪,因为它们占用太多空间。
为了避免错误,您可以改用 TryParse 方法。 TryParse 尝试转换输入 string ,如果可以转换,则返回 true ,如果不能,则返回 false 。异常是一种相对昂贵的操作,因此应尽可能避免。
out 关键字是必需的,以允许 TryParse 方法在转换成功时设置 count 变量。
让我们看看 TryParse 的实际应用:
- 将 int count 声明替换为使用 TryParse 方法的语句,并要求用户输入鸡蛋的数量,如以下代码所示:
Write("How many eggs are there? ");
string? input = ReadLine();
if (int.TryParse(input, out int count))
{
WriteLine(#34;There are {count} eggs.");
}
else
{
WriteLine("I could not parse the input.");
}
运行代码,输入 12 ,并查看结果,如下输出所示:
How many eggs are there? 12
There are 12 eggs.
运行代码,输入 twelve ,并查看结果,如下输出所示:
How many eggs are there? twelve
I could not parse the input.
您还可以使用 System.Convert 类型的方法将 string 值转换为其他类型;但是,与 Parse 方法一样,如果无法转换,它会给出错误。
理解 Try 方法命名约定
.NET 使用标准签名来定义所有遵循 Try 命名约定的方法。对于任何名为 Something 且返回特定类型值的方法,其匹配的 TrySomething 方法必须返回一个 bool 以指示成功或失败,并使用 out 参数代替返回值。例如:
// A method that might throw an exception.
int number = int.Parse("123");
// The Try equivalent of the method.
bool success = int.TryParse("123", out int number);
// Trying to create a Uri for a Web API.
bool success = Uri.TryCreate("https://localhost:5000/api/customers",
UriKind.Absolute, out Uri serviceUrl);
处理异常
您已经看到在转换类型时发生错误的几种情况。一些语言在出现问题时返回错误代码。.NET 使用异常,这些异常更丰富,仅用于故障报告。当这种情况发生时,我们说抛出了一个运行时异常。
其他系统可能使用具有多重用途的返回值。例如,如果返回值是正数,它可能表示表中的行数;如果返回值是负数,它可能表示某个错误代码。
一些第三方库使得定义可以指示错误和成功的“结果”类型变得更加容易。许多 .NET 开发人员更喜欢使用它们,而不是抛出异常。您可以在本章末尾的一个可选在线部分中了解更多信息。
当抛出异常时,线程会被挂起,如果调用代码定义了一个 try-catch 语句,则它有机会处理该异常。如果当前方法没有处理它,则其调用方法将有机会处理,依此类推,直到调用栈的顶部。
正如您所看到的,控制台应用程序的默认行为是输出有关异常的消息,包括堆栈跟踪,然后停止运行代码。应用程序被终止。这比允许代码在潜在的损坏状态下继续执行要好。您的代码应该只捕获和处理它理解并能够正确修复的异常。
良好实践:尽量避免编写会抛出异常的代码,可以通过执行 if 语句检查来实现。有时你无法避免,有时最好让异常被调用你代码的更高层组件捕获。你将在第 4 章“编写、调试和测试函数”中学习如何做到这一点。
在 .NET 9 中,异常处理采用基于 NativeAOT 异常处理模型的新实现。这在 .NET 团队的基准测试中将异常处理性能提高了 2 到 4 倍。
将易出错的代码包装在 try 块中
当您知道某个语句可能会导致错误时,您应该将该语句包装在 try 块中。例如,从文本解析为数字可能会导致错误。只有在 try 块中的语句抛出异常时, catch 块中的任何语句才会被执行。
我们不需要在 catch 块内做任何事情。让我们看看这个实际效果:
- 使用您喜欢的代码编辑器向 Chapter03 解决方案添加一个名为 HandlingExceptions 的新控制台应用程序 / console 项目。
- 在 Program.cs 中,删除任何现有的语句,然后输入语句提示用户输入他们的年龄,然后将他们的年龄写入控制台,如以下代码所示:
WriteLine("Before parsing");
Write("What is your age? ");
string? input = ReadLine();
try
{
int age = int.Parse(input);
WriteLine(#34;You are {age} years old.");
}
catch
{
}
WriteLine("After parsing");
您将看到以下编译器消息: Warning CS8604 Possible null reference argument for parameter 's' in 'int int.Parse(string s)' 。
默认情况下,在 .NET 6 或更高版本的项目中,Microsoft 启用了可空引用类型,因此您会看到更多类似的编译器警告。在生产代码中,您应该添加代码以检查 null 并适当地处理这种可能性,如以下代码所示:
if (input is null)
{
WriteLine("You did not enter a value so the app has ended.");
return; // Exit the app.
}
在本书中,我不会每次都给出添加这些 null 检查的指示,因为代码示例并不是为生产质量而设计的,随处都有 null 检查会使代码变得杂乱,并占用宝贵的页面。
您可能会在本书中的代码示例中看到数百个潜在的 null 变量示例。对于本书的代码示例,这些警告可以安全忽略。您只需在编写自己的生产代码时关注类似的警告。关于空值处理的更多内容将在第六章中讨论,主题是实现接口和继承类。
在这种情况下, input 不可能是 null ,因为用户必须按 Enter 键才能返回 ReadLine ,如果他们在那时没有输入任何字符,那么 ReadLine 方法将返回一个空的 string 。让我们告诉编译器它不需要向我们显示这个警告:
- 要禁用编译器警告,请将 input 更改为 input! ,如以下代码中突出显示的所示:
int age = int.Parse(input!);
感叹号 ! 在表达式后被称为空值宽容操作符,它禁用编译器警告。空值宽容操作符在运行时没有效果。如果表达式在运行时可能评估为 null ,可能是因为我们以其他方式进行了赋值,那么将抛出异常。
此代码包含两个消息,以指示解析前和解析后,以使代码的流程更加清晰。随着示例代码变得更加复杂,这些将特别有用。
- 运行代码,输入 49 ,并查看结果,如下输出所示:
Before parsing
What is your age? 49
You are 49 years old.
After parsing
运行代码,输入 Kermit ,并查看结果,如下输出所示:
Before parsing
What is your age? Kermit
After parsing
当代码执行时,捕获到了错误异常,默认消息和堆栈跟踪没有输出,控制台应用程序继续运行。这比默认行为要好,但查看发生的错误类型可能会很有用。
良好实践:在生产代码中,您绝不应使用像这样的空 catch 语句,因为它会“吞噬”异常并隐藏潜在问题。如果您无法或不想正确处理异常,至少应该记录该异常,或者重新抛出它,以便更高层的代码可以决定如何处理。您将在第 4 章“编写、调试和测试函数”中学习有关日志记录的内容。
捕获所有异常
要获取可能发生的任何类型异常的信息,您可以在 catch 块中声明一个类型为 System.Exception 的变量:
- 在 catch 块中添加一个异常变量声明,并使用它将异常信息写入控制台,如以下代码所示:
catch (Exception ex)
{
WriteLine(#34;{ex.GetType()} says {ex.Message}");
}
运行代码,再次输入 Kermit ,并查看结果,如下输出所示:
Before parsing
What is your age? Kermit
System.FormatException says Input string was not in a correct format.
After parsing
捕获特定异常
现在我们知道发生了哪种特定类型的异常,我们可以通过捕获该类型的异常并自定义显示给用户的消息来改进我们的代码。你可以把这看作是一种测试形式:
- 保留现有的 catch 块,并在其上方添加一个新的 catch 块,用于格式异常类型,如以下高亮代码所示:
catch (FormatException)
{
WriteLine("The age you entered is not a valid number format.");
}
catch (Exception ex)
{
WriteLine(#34;{ex.GetType()} says {ex.Message}");
}
运行代码,再次输入 Kermit ,并查看结果,如下输出所示:
Before parsing
What is your age? Kermit
The age you entered is not a valid number format.
After parsing
我们想在下面保留更一般的 catch 的原因是可能会发生其他类型的异常。
- 运行代码,输入 9876543210 ,并查看结果,如下输出所示:
Before parsing
What is your age? 9876543210
System.OverflowException says Value was either too large or too small for an Int32.
After parsing
让我们为这种类型的异常添加另一个 catch 块。
- 保留现有的 catch 块,并为溢出异常类型添加一个新的 catch 块,如下方高亮代码所示:
catch (OverflowException)
{
WriteLine("Your age is a valid number format but it is either too big or small.");
}
catch (FormatException)
{
WriteLine("The age you entered is not a valid number format.");
}
运行代码,输入 9876543210 ,并查看结果,如下输出所示:
Before parsing
What is your age? 9876543210
Your age is a valid number format but it is either too big or small.
After parsing
捕获异常的顺序很重要。正确的顺序与异常类型的继承层次结构有关。您将在第 5 章《使用面向对象编程构建自己的类型》中学习继承。不过,不用太担心这一点——如果您以错误的顺序捕获异常,编译器会给您构建错误。
良好实践:避免过度捕获异常。它们通常应该被允许在调用栈中向上传播,以便在更了解可能改变处理逻辑的情况的层面上进行处理。您将在第 4 章“编写、调试和测试函数”中学习到这一点。
使用过滤器捕获
您还可以使用 when 关键字向 catch 语句添加过滤器,如以下代码所示:
Write("Enter an amount: ");
string amount = ReadLine()!;
if (string.IsNullOrEmpty(amount)) return;
try
{
decimal amountValue = decimal.Parse(amount);
WriteLine(#34;Amount formatted as currency: {amountValue:C}");
}
catch (FormatException) when (amount.Contains('#39;))
{
WriteLine("Amounts cannot use the dollar sign!");
}
catch (FormatException)
{
WriteLine("Amounts must only contain digits!");
}
良好实践: string 类型上的 Contains 方法对使用双引号传递的 string 值和使用单引号传递的 char 值都有重载。当您想检查一个字符,例如美元符号时,使用前面代码中的 char 重载会更高效。
检查溢出情况
之前,我们看到在数字类型之间进行转换时,可能会丢失信息,例如,从一个 long 变量转换为一个 int 变量。如果存储在某个类型中的值过大,它将溢出。
使用 checked 语句抛出溢出异常
checked 语句告诉 .NET 在发生溢出时抛出异常,而不是默默地允许它发生,这在默认情况下是出于性能考虑。
我们将把 int 变量的初始值设置为其最大值减去一。然后,我们将多次递增它,每次输出它的值。一旦它超过最大值,它将溢出到最小值,并从那里继续递增。
让我们看看这个实际应用:
- 在 Program.cs 中,输入语句以声明并将一个整数赋值为其最大可能值减一,然后递增它并将其值写入控制台三次,如以下代码所示:
int x = int.MaxValue - 1;
WriteLine(#34;Initial value: {x}");
x++;
WriteLine(#34;After incrementing: {x}");
x++;
WriteLine(#34;After incrementing: {x}");
x++;
WriteLine(#34;After incrementing: {x}");
运行代码并查看结果,结果显示值静默溢出并环绕到较大的负值,如下输出所示:
Initial value: 2147483646
After incrementing: 2147483647
After incrementing: -2147483648
After incrementing: -2147483647
现在,让我们通过使用 checked 语句块来包装语句,以便让编译器警告我们关于溢出的问题,如以下代码中突出显示的那样:
checked
{
int x = int.MaxValue - 1;
WriteLine(#34;Initial value: {x}");
x++;
WriteLine(#34;After incrementing: {x}");
x++;
WriteLine(#34;After incrementing: {x}");
x++;
WriteLine(#34;After incrementing: {x}");
}
运行代码并查看结果,显示溢出被检查并导致抛出异常,如以下输出所示:
Initial value: 2147483646
After incrementing: 2147483647
Unhandled Exception: System.OverflowException: Arithmetic operation resulted in an overflow.
就像其他任何异常一样,我们应该将这些语句包装在一个 try 语句块中,并为用户显示一个更友好的错误消息,如以下代码所示:
try
{
// previous code goes here
}
catch (OverflowException)
{
WriteLine("The code overflowed but I caught the exception.");
}
运行代码并查看结果,如下所示的输出:
Initial value: 2147483646
After incrementing: 2147483647
The code overflowed but I caught the exception.?
使用 unchecked 语句禁用编译器溢出检查
上一节讨论了运行时的默认溢出行为以及如何使用 checked 语句来改变该行为。本节讨论的是编译时的溢出行为以及如何使用 unchecked 语句来改变该行为。
相关的关键字是 unchecked 。这个关键字关闭编译器在代码块内执行的溢出检查。让我们看看如何做到这一点:
- 在前面的语句末尾输入以下语句。编译器不会编译此语句,因为它知道会溢出:
int y = int.MaxValue + 1;
- 将鼠标指针悬停在错误上,可以注意到编译时检查显示为错误消息,如图 3.4 所示:
要禁用编译时检查,请将语句包装在 unchecked 块中,将 y 的值写入控制台,递减它,然后重复,如以下代码所示:
unchecked
{
int y = int.MaxValue + 1;
WriteLine(#34;Initial value: {y}");
y--;
WriteLine(#34;After decrementing: {y}");
y--;
WriteLine(#34;After decrementing: {y}");
}
运行代码并查看结果,如下所示的输出:
Initial value: -2147483648
After decrementing: 2147483647
After decrementing: 2147483646
当然,您很少会想要显式地关闭这样的检查,因为这会导致溢出发生。但也许您可以想象一个您可能希望这种行为的场景。