C# 13 和 .NET 9 全知道 :5 构建您自己的类型——面向对象编程 (2)
存储数据在字段中
在这一节中,我们将定义类中的一些字段以存储有关个人信息。
定义字段
假设我们决定一个人由姓名和出生日期时间组成。我们将这两个值封装在一个人中,这些值将对外可见:
- 在 Person 类内部,编写语句声明两个公共字段以存储一个人的姓名和他们的出生日期,如下代码所示
public class Person : object
{
#region Fields: Data or state for this person.
public string? Name; // ? means it can be null.
public DateTimeOffset Born;
#endregion
}
我们有多个选择用于 Born 字段的 数据类型。.NET 6 引入了 DateOnly 类型。这将仅存储日期而没有时间值。 DateTime 存储出生日期和时间,但它在本地时间和协调世界时(UTC)之间有所不同。最佳选择是 DateTimeOffset ,它存储日期、时间和从 UTC 偏移的小时数,这与时区相关。选择取决于您需要存储多少细节。
字段类型
自 C# 8 以来,编译器能够警告您如果引用类型,如 string ,可能具有 null 值,因此可能抛出 NullReferenceException 。自.NET 6 以来,SDK 默认启用这些警告。您可以在 string 类型后缀一个问号 ? ,以表示您接受这一点,警告就会消失。您将在第 6 章“实现接口和继承类”中了解更多关于可空性和如何处理它的信息。
您可以使用任何类型的字段,包括数组集合,如列表和字典。这些将在您需要在一个命名字段中存储多个值时使用。在这个例子中,一个人只有一个姓名和一个出生日期和时间。
成员访问修饰符
封装的一部分是选择其他代码对成员的可见性。
请注意,就像我们对类所做的那样,我们明确地应用了 public 关键字到这些字段上。如果我们没有这样做,那么它们将隐式地 private 到类中,这意味着它们只能在类内部访问。
有四个成员访问修饰符关键字,以及两个可以应用于类成员(如字段或方法)的访问修饰符关键字组合。成员访问修饰符应用于单个成员。它们与应用于整个类型的类型访问修饰符类似,但又是分开的。六种可能的组合在表 5.1 中显示:
成员访问修饰符 | 描述 |
private | 成员仅可在类型内部访问。这是默认设置。 |
internal | 成员可在类型内部访问,以及同一程序集中的任何类型。 |
protected | 成员在类型内部可访问,以及从该类型继承的任何类型。 |
public | 成员无处不在可访问。 |
internal protected | 成员可在类型内部访问,同一程序集内的任何类型,以及从该类型继承的任何类型。相当于一个虚构的访问修饰符名为 internal_or_protected 。 |
private protected | 成员在类型内部可访问,以及从该类型继承的任何类型,且位于同一程序集内。相当于名为 internal_and_protected 的虚构访问修饰符。此组合仅在 C# 7.2 或更高版本中可用。 |
表 5.1:六成员访问修饰符
良好实践:明确为所有类型成员应用一个访问修饰符,即使您想使用隐式访问修饰符 private ,对于成员。此外,字段通常应该是 private 或 protected ,然后创建 public 属性来获取或设置字段值。这是因为属性可以控制访问。您将在本章后面这样做。
设置和输出字段值
现在,我们将在您的代码中使用这些字段:
- 在 Program.cs 中,在实例化 bob 之后,添加设置他的姓名和出生日期时间的语句,然后以格式良好的方式输出这些字段,如下所示代码:
bob.Name = "Bob Smith";
bob.Born = new DateTimeOffset(
year: 1965, month: 12, day: 22,
hour: 16, minute: 28, second: 0,
offset: TimeSpan.FromHours(-5)); // US Eastern Standard Time.
WriteLine(format: "{0} was born on {1:D}.", // Long date.
arg0: bob.Name, arg1: bob.Born);
arg1 的格式代码是标准日期和时间格式之一。 D 表示长日期格式, d 则表示短日期格式。您可以在以下链接中了解更多有关标准日期和时间格式代码的信息:https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings。
- 运行 PeopleApp 项目并查看结果,如下所示输出:
Bob Smith was born on Wednesday, December 22, 1965.
如果您将调用 ConfigureConsole 改为使用您本地的计算机文化或指定的文化,例如法国的法语( " fr-FR" ),那么您的输出将会有所不同。
使用对象初始化语法设置字段值
您也可以使用花括号简写对象初始化语法来初始化字段,这是从 C# 3.0 开始引入的。让我们看看如何操作:
- 在现有代码下方添加语句以创建另一个名为 Alice 的新人物。注意在控制台写入她时日期和时间的不同标准格式代码,如下所示代码所示:
Person alice = new()
{
Name = "Alice Jones",
Born = new(1998, 3, 7, 16, 28, 0,
// This is an optional offset from UTC time zone.
TimeSpan.Zero)
};
WriteLine(format: "{0} was born on {1:d}.", // Short date.
arg0: alice.Name, arg1: alice.Born);
我们可以使用字符串插值来格式化输出,但对于长字符串,它会在多行中换行,这在印刷书籍中可能更难阅读。在这本书的代码示例中,请记住 {0} 是 arg0 的占位符,依此类推。
- 运行 PeopleApp 项目并查看结果,如下所示输出:
Alice Jones was born on 3/7/1998.
好的做法:使用命名参数传递参数,这样更清楚地知道值的含义,尤其是对于像 DateTimeOffset 这样的类型,其中有一系列数字连续出现。
使用枚举类型存储值
有时,一个值需要是有限选项之一。例如,世界有七大奇迹,一个人可能有一个最喜欢的。
在其他时候,一个值需要是有限选项的组合。例如,一个人可能有他们想要参观的古代世界奇迹清单。我们可以通过定义一个 enum 类型来存储这些数据。
一种 enum 类型是存储一个或多个选择的一种非常有效的方式,因为它在内部使用整数值与 string 描述的查找表相结合。让我们看一个例子:
- 向 PacktLibraryNet2 项目添加一个名为 WondersOfTheAncientWorld.cs 的新文件。
- 修改 WondersOfTheAncientWorld.cs 内容,如下所示代码:
namespace Packt.Shared;
public enum WondersOfTheAncientWorld
{
GreatPyramidOfGiza,
HangingGardensOfBabylon,
StatueOfZeusAtOlympia,
TempleOfArtemisAtEphesus,
MausoleumAtHalicarnassus,
ColossusOfRhodes,
LighthouseOfAlexandria
}
- 在 Person.cs 中,定义一个字段来存储一个人的最爱古代世界奇迹,如下代码所示:
public WondersOfTheAncientWorld FavoriteAncientWonder;
在 Program.cs 中,设置 Bob 最喜欢的世界古代奇迹并输出,如下代码所示:
bob.FavoriteAncientWonder = WondersOfTheAncientWorld.StatueOfZeusAtOlympia;
WriteLine(
format: "{0}'s favorite wonder is {1}. Its integer is {2}.",
arg0: bob.Name,
arg1: bob.FavoriteAncientWonder,
arg2: (int)bob.FavoriteAncientWonder);
- 运行 PeopleApp 项目并查看结果,如下所示输出:
Bob Smith's favorite wonder is StatueOfZeusAtOlympia. Its integer is 2.
enum 值在内部以 int 的形式存储以提高效率。 int 值将自动分配,从 0 开始,因此我们 enum 中的第三个世界奇迹的值为 2 。您可以分配不在 enum 列表中的 int 值。由于找不到匹配项,它们将输出为 int 值而不是名称。
使用枚举类型存储多个值
对于愿望清单,我们可以创建一个包含 enum 实例的数组或集合,集合作为字段将在本章后面展示,但针对这种情况有一个更好的方法。我们可以使用 enum 标志将多个选项合并为一个值。让我们看看如何:
- 修改 enum ,通过装饰它为 [Flags] 属性,并为每个代表不同位列的奇迹显式设置一个 byte 值,如下代码所示:
namespace Packt.Shared;
[Flags]
public enum WondersOfTheAncientWorld : byte
{
None = 0b_0000_0000, // i.e. 0
GreatPyramidOfGiza = 0b_0000_0001, // i.e. 1
HangingGardensOfBabylon = 0b_0000_0010, // i.e. 2
StatueOfZeusAtOlympia = 0b_0000_0100, // i.e. 4
TempleOfArtemisAtEphesus = 0b_0000_1000, // i.e. 8
MausoleumAtHalicarnassus = 0b_0001_0000, // i.e. 16
ColossusOfRhodes = 0b_0010_0000, // i.e. 32
LighthouseOfAlexandria = 0b_0100_0000 // i.e. 64
}
整型类型, enum 允许继承自的有 Byte 、 SByte 、 Int16 、 Int32 、 Int64 、 UInt16 、 UInt32 和 UInt64 。新的整型类型 Int128 和 UInt128 不受支持。
我们为每个选择分配明确的值,这些值在查看存储在内存中的位时不会重叠。我们还应该用 System.Flags 属性装饰 enum 类型,以便在返回值时,它可以自动匹配多个值,以逗号分隔的 string 形式返回,而不是返回 int 值。
通常, enum 类型在内部使用 int 变量,但由于我们不需要那么大的值,我们可以通过将其设置为使用 byte 变量来减少 75%的内存需求,即每个值 1 字节而不是 4 字节。作为另一个例子,如果您想定义一周中的天数,那么将始终只有七天。
如果我们要表明我们的愿望清单包括巴比伦空中花园和哈利卡纳苏斯的陵墓,这些古代世界奇迹,那么我们希望将 16 和 2 位设置为 1 。换句话说,我们将存储值 18 ,如表 5.2 所示:
64 | 32 | 16 | 8 | 4 | 2 | 1 |
0 | 0 | 1 | 0 | 0 | 1 | 0 |
表 5.2:将 18 以位的形式存储在枚举中
- 在 Person.cs 中,保留现有字段以存储单个喜爱的古代世界奇迹,并将以下声明添加到您的字段列表中,以存储多个古代世界奇迹:
public WondersOfTheAncientWorld BucketList;
在 Program.cs 中,添加语句使用 | 运算符(位逻辑运算 OR )组合 enum 值来设置 bucket 列表。我们也可以像注释中所示,使用将数字 18 转换为 enum 类型的值来设置该值,但我们不应该这样做,因为这会使代码更难以理解,如下面的代码所示:
bob.BucketList =
WondersOfTheAncientWorld.HangingGardensOfBabylon
| WondersOfTheAncientWorld.MausoleumAtHalicarnassus;
// bob.BucketList = (WondersOfTheAncientWorld)18;
WriteLine(#34;{bob.Name}'s bucket list is {bob.BucketList}.");
- 运行 PeopleApp 项目并查看结果,如下所示输出:
Bob Smith's bucket list is HangingGardensOfBabylon, MausoleumAtHalicarnassus.
良好实践:使用 enum 值存储离散选项的组合。如果有最多八个选项,则从 byte 派生 enum 类型;如果有最多 16 个选项,则从 ushort 派生;如果有最多 32 个选项,则从 uint 派生;如果有最多 64 个选项,则从 ulong 派生。
现在我们已经用 [Flags] 属性装饰了 enum ,可以将在单个变量或字段中存储值的组合。现在,当程序员只应存储一个值时,他们也可以在 FavoriteAncientWonder 中存储值的组合。为了强制执行这一点,我们应该将字段转换为属性,这样我们就可以控制其他程序员如何获取和设置值。你将在本章后面看到如何做到这一点。
修改枚举基类型以提高性能
上一节是关于使用 enum 类型存储多个值。这是关于带有 [Flags] 属性的 enum 类型,它们使用位运算有效地存储这些多个值。在代码示例中,我们定义了一个 enum 用于古代世界的七大奇迹,因此只需要七个可组合的值(以及 0 用于 None )。
上一节并不是关于让所有你的 enum 类型派生自 byte 以使你的代码更快,因为这将是糟糕的建议。
2024 年 3 月 18 日,Nick Chapsas 发布了一段名为 “立即将你所有的枚举变成字节”的 YouTube 视频! |Code Cop #014,您可以通过以下链接观看:https://www.youtube.com/watch?v=1gWzE9SIGkQ。他批评了那些建议将 enum 类型的默认基本整数类型从 int 更改为 byte 以提高性能的博客文章。
C#语言的原设计者花费了努力来实现 enum 类型能够从除了默认的 int 以外的其他整数派生。例如,您可以通过使用正整数如 byte 或 ushort 来减少字节的使用,或者使用正整数如 uint 或 ulong 来使用相同或更多的字节。他们实现这个特性是因为有时.NET 开发者需要这种能力。
我认为让我的读者知道在必要时他们可以做到这一点很重要。微软的官方指南指出:“尽管您可以更改此基础类型,但在大多数情况下,没有必要也不推荐这样做。使用小于 Int32 的数据类型并不会带来显著的性能提升。” 如您在以下链接中阅读到的:https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1028。
对于那些反对将任何 enum 从 int 更改为其他整数的开发者,上面我已经链接了一个编译器代码分析警告。如果启用,当您将 enum 设置为除 int 之外的其他值时,它将会触发:“CA1028:枚举存储应为 Int32。”这个警告默认未启用,因为微软知道开发者可能需要使用它的合法理由。
让我们看看一些实际例子,说明何时需要将 enum 从派生自 int 改为派生自另一种整数类型:
- 您想增加整数的存储大小,以便在标志 enum 中存储超过 16 个选项。默认的 int 只允许 16 个选项:0、1、2、4、8、16、32、64、128、256、512、1024、2048、4096、8192 和 16384。改为 uint 将使选项数量翻倍至 32,而不占用更多内存空间。改为 ulong 将提供 64 个选项。改为 ushort 将允许相同的 16 个选项,但占用字节减半。
- 您需要通过串行连接将数据作为二进制流传输到嵌入式设备,并且必须仔细遵循协议;或者您正在定义自己的串行消息协议,并希望减小数据包大小以充分利用可用带宽。
- 您有包含数百万条记录的 SQL 表,其中一些列是 enum 值。在 C#实体类中将这些列设置为 tinyint ,并匹配 enum : byte 属性,可以使索引性能更好,通过变得更小并减少从磁盘读取的页面数。一些开发者会在 30 年或更老的旋转金属磁盘的系统上工作。并非每个人都部署到现代 64 位操作系统和现代硬件上。
- 您需要减小 struct 的大小,因为它将在资源受限的硬件上每秒创建 100,000 次,或者您的游戏代码被设置为使用 byte 和 short ,因为您有数百万个连续数组用于游戏数据。这样做可以显著提高性能,尤其是在缓存方面。
现在,让我们看看如何使用集合存储多个值。
使用集合存储多个值
现在让我们添加一个字段来存储一个人的孩子。这是一个聚合的例子,因为孩子是相关于当前人的类的实例,但它们不是人本身的一部分。我们将使用一个通用的 List<T> 集合类型,它可以存储任何类型的有序集合。你将在第 8 章“使用常见的.NET 类型”中了解更多关于集合的内容。现在,只需跟随操作:
- 在 Person.cs 中,声明一个新的字段以存储代表此人的子代多个 Person 实例,如下代码所示:
public List<Person> Children = new();
List<Person> 读作“ Person 的列表”,例如,“名为 Children 的属性的类型是 Person 实例的列表。”
我们必须在向其中添加项目之前确保集合被初始化为一个新的实例;否则,当尝试使用其任何成员,如 Add 时,该字段将为 null 并抛出运行时异常。
理解泛型集合
尖括号在 List<T> 类型中是 C#中名为泛型的特性,该特性于 2005 年随 C# 2.0 版本引入。这是一个用于使集合强类型化的术语,也就是说,编译器知道可以存储在集合中的特定对象类型。泛型可以提高代码的性能和正确性。
强类型与静态类型含义不同。旧的 System.Collection 类型是静态类型,用于包含弱类型 System.Object 项。较新的 System.Collection.Generic 类型是静态类型,用于包含强类型 <T> 实例。
讽刺的是,泛型一词意味着我们可以使用更具体的静态类型!
- 在 Program.cs 中,添加语句为 Bob 添加三个孩子,然后显示他有多少个孩子以及他们的名字,如下代码所示:
// Works with all versions of C#.
Person alfred = new Person();
alfred.Name = "Alfred";
bob.Children.Add(alfred);
// Works with C# 3 and later.
bob.Children.Add(new Person { Name = "Bella" });
// Works with C# 9 and later.
bob.Children.Add(new() { Name = "Zoe" });
WriteLine(#34;{bob.Name} has {bob.Children.Count} children:");
for (int childIndex = 0; childIndex < bob.Children.Count; childIndex++)
{
WriteLine(#34;> {bob.Children[childIndex].Name}");
}
运行 PeopleApp 项目并查看结果,如下所示输出:
Bob Smith has 3 children:
> Alfred
> Bella
> Zoe
我们也可以使用 foreach 语句来遍历集合。作为一个可选的挑战,将 for 语句改为使用 foreach 输出相同的信息。
将字段设置为静态
我们迄今为止创建的字段都是实例成员,这意味着每个创建的类实例的每个字段都有不同的值。 alice 和 bob 变量有不同的 Name 值。
有时,您想定义一个只具有一个值且在所有实例中共享的字段。
这些被称为静态成员,因为字段不是唯一可以静态的成员。让我们看看使用银行账户作为例子,使用 static 字段可以实现什么。每个 BankAccount 的实例都将有自己的 AccountName 和 Balance 值,但所有实例将共享一个单一的 InterestRate 值。
让我们做吧:
- 在 PacktLibraryNet2 项目中,添加一个名为 BankAccount.cs 的新类文件。
- 修改该类,使其包含三个字段——两个实例字段和一个静态字段——如下代码所示:
namespace Packt.Shared;
public class BankAccount
{
public string? AccountName; // Instance member. It could be null.
public decimal Balance; // Instance member. Defaults to zero.
public static decimal InterestRate; // Shared member. Defaults to zero.
}
在 Program.cs 中添加设置共享利率的语句,然后创建两个 BankAccount 类型的实例,如下所示:
BankAccount.InterestRate = 0.012M; // Store a shared value in static field.
BankAccount jonesAccount = new();
jonesAccount.AccountName = "Mrs. Jones";
jonesAccount.Balance = 2400;
WriteLine(format: "{0} earned {1:C} interest.",
arg0: jonesAccount.AccountName,
arg1: jonesAccount.Balance * BankAccount.InterestRate);
BankAccount gerrierAccount = new();
gerrierAccount.AccountName = "Ms. Gerrier";
gerrierAccount.Balance = 98;
WriteLine(format: "{0} earned {1:C} interest.",
arg0: gerrierAccount.AccountName,
arg1: gerrierAccount.Balance * BankAccount.InterestRate);
运行 PeopleApp 项目并查看附加输出:
Mrs. Jones earned $28.80 interest.
Ms. Gerrier earned $1.18 interest.
记住 C 是一个格式代码,它告诉.NET 使用当前文化的货币格式来表示十进制数字。
字段不是唯一可以声明为静态的成员。构造函数、方法、属性和其他成员也可以是静态的。
静态方法不需要对象实例即可调用。例如, Console.WriteLine 不需要对象实例;方法直接从类名调用。静态方法在第 6 章中介绍,实现接口和继承类。
设置字段为常量
如果字段的值永远不会改变,您可以使用 const 关键字并在编译时分配一个字面值。任何更改值的语句都会导致编译时错误。让我们看一个简单的例子:
- 在 Person.cs 中,添加一个表示人的物种的 string 常量,如下所示代码:
// Constant fields: Values that are fixed at compilation.
public const string Species = "Homo Sapiens";
要获取常量字段的值,必须写出类的名称,而不是类的实例名称。在 Program.cs 中,添加一条语句将 Bob 的名字和种类写入控制台,如下所示代码:
// Constant fields are accessible via the type.
WriteLine(#34;{bob.Name} is a {Person.Species}.");
- 运行 PeopleApp 项目并查看结果,如下所示输出:
Bob Smith is a Homo Sapiens.
示例中 Microsoft 类型中的 const 字段包括 System.Int32.MaxValue 和 System.Math.PI ,因为这两个值永远不会改变,如图 5.2 所示:
良好的实践:常量并不总是最佳选择,原因有两个:值必须在编译时已知,并且必须能表示为文字 string 、 Boolean 或数值。每次引用 const 字段时,都会在编译时用文字值替换,因此,如果在未来版本中值发生变化,并且您没有重新编译引用它的任何程序集以获取新值,则这种变化将不会反映出来。