C# 学习笔记-构造自己的类型

用 OOP 构建自己的类型

Building Your Own Types with Object-Oriented Programming

本章主题:

  • 讨论 OOP
  • 构建类库
  • 在字段 field 中存储数据
  • 使用方法与元组 tuple
  • 使用属性和索引器控制访问
  • 使用 object 进行模式匹配(Pattern matching)
  • 使用 record 类型

讨论 OOP

在 C# 中,使用关键字 classrecordstruct 来定义一个 object 的类型。

OOP 的概念如下:

  • Encapsulation 封装,即与对象相关的数据和操作的组合。
  • Composition 组合,是关于对象是由什么组成的。
  • Aggregation 聚合,是关于可以与对象组合的东西。
  • Inheritance 继承,指通过从基类或超类派生子类来重用代码。
  • Abstraction 抽象,指捕捉对象的核心思想并忽略细节 details 或具体 specifics。C# 具有abstract 关键字来形式化这个概念,但不要将抽象概念与 abstract 关键字的使用含义混淆,因为它的含义不止于此。抽象的概念也可以使用接口来实现。如果一个类不是明确抽象的,那么它可以被描述为具体的(concrete)。基类或超类通常是抽象的;
  • Polymorphism 多态性是指允许派生类重写继承的操作以提供自定义行为。

构建类库

Building class libraries

类库程序集将类型组合成易于部署的单元(DLL 文件)。为了使您编写的代码可在多个项目中重用,您应该将其放入类库程序集中,就像 Microsoft 所做的那样。

我们创建一个类库项目:

mkdir Chapter05
cd Chapter05
dotnet new sln -n Chapter05
dotnet new classlib -n PacktLibraryNetStandard2

我们注意到项目的 .csproj 文件中还有目标框架版本 TargetFramework

<PropertyGroup>
    <TargetFramework>net7.0TargetFramework>
    <ImplicitUsings>enableImplicitUsings>
    <Nullable>enableNullable>
PropertyGroup>

我们可以进行相关修改:

更改目标 .NET Standard 2.0;指定 C# 版本为 12;并为所有文件静态引入 System.Console 类:

<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
    
    <TargetFramework>netstandard2.0TargetFramework>
    
    <LangVersion>12LangVersion>
    <Nullable>enableNullable>
    <ImplicitUsings>enableImplicitUsings>
PropertyGroup>
<ItemGroup>
	<Using Include="System.Console" Static="true" />
ItemGroup>
Project>

尽管我们使用了 C#12 编译器,但是一些现代编译器特性还需要一个现代 .NET 运行时。

良好实践:要使用所有最新的 C# 语言和 .NET 平台功能,请将类型放入 .NET 8 类库中。为了支持 .NET Core、.NET Framework 和 Xamarin 等旧版 .NET 平台,请将可能重用的类型放入 .NET Standard 2.0 类库中。默认情况下,面向 .NET Standard 2.0 使用 C# 7 编译器,但这可以被覆盖,因此即使您仅限于 .NET Standard 2.0 API,您也可以获得较新的 SDK 和编译器的优势。

文件域名称空间

以往,我们定义一个类型(如类)将其嵌套在一个名称空间中,如下所示:

namespace Packt.Shared
{
    public class Person
    {
    }
}

如果要在同一个文件中顶一个多个类型,而这些类型在不同的名称空间中,则要使用大括号将这些类型分别包含在对应的名称空间的大括号中。

C#10 引入了文件域的名称空间,我们只需要声明一个名称空间,则对整个文件都有效:

namespace Packt.Shared;
public class Person
{
}

这就是 file-scoped namespace(文件域名称空间)

在一个名称空间中定义一个类

Defining a class in a namespace

我们在类库项目中定义一个文件为 Person.cs ,内容:

namespace Packt.Shared;
public class Person
{
}

注意,为了使该类可以被访问,使用了 public 关键字。

类型访问修饰符

Understanding type access modifiers

注意到上述代码中 class 前的 public 关键字,这就是一个访问修饰符(access modifier),它允许其来自类库外的其他任何代码能够访问这个类。

如果不显式应用 public 关键字,则只能在定义它的程序集(assembly)中访问它。这是因为类的隐式访问修饰符是 internal。我们需要这个类可以在程序集外部访问,所以我们必须确保它是公共的。

如果是嵌套类,即在一个类 A 中定义另一个类 B,则内部这个类 B 的访问修饰符是 private,这意味着不可以在类 A 以外的地方访问。

.NET 7 引入了文件访问修饰符 file,这意味着该类型只能在本文件中使用

良好实践:类的两个最常见的访问修饰符是 public internal(类的默认访问修饰符)。始终显式指定类的访问修饰符,以明确它是什么。其他访问修饰符包括 private file 很少被使用。

成员

Understanding members

成员可以是 字段、方法或两者的特殊版本、具体描述如下:

**字段(field)**被用来存储数据。可以认为字段是某种类型的变量。有三种字段:

  • 常量(constant):该数据从未改变。编译器实际上将数据复制到任何读取它的代码中。
  • 只读(read-only):该类被实例化后只读数据就不能被更改,但是只读数据可以在实例化时从而外的来源计算或加载。
  • 事件(Event):该数据引用一个或多个方法,这些方法将在某些事情发生时执行。比如点击按钮或接收请求。

方法(Method)用于执行语句。有四种方法:

  • 构造器(constructor),将会在使用 new 关键字来申请内存来实例化一个类时执行。
  • 属性(property),当设置(set)或读取(get)数据时。数据往往被存储在一个字段中,但也可以存储在外部或在运行时计算。除非需要公开字段的内存地址,否则属性是封装(encapsulate)字段的首选方式,例如,使用 Console.ForegroundColor 设置控制台应用程序中文本的当前颜色。
  • 索引(indexer),用于使用语法 [] 时获取或设置数据。
  • 操作符(operator),就是操作符重载

导入一个名称空间来使用一个类型

如果我们想使用类库中的类型,需要引用该类库项目。

我们创建一个 控制台项目:

dotnet new console -n PeopleApp
dotnet sln add PeopleApp

修改项目的 .csproj 文件以引入 Person 类型:

<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
    <OutputType>ExeOutputType>
    <TargetFramework>net8.0TargetFramework>
    <Nullable>enableNullable>
    <ImplicitUsings>enableImplicitUsings>
PropertyGroup>
    
<ItemGroup>
    <ProjectReference Include="../PacktLibraryNetStandard2/PacktLibraryNetStandard2.csproj" />
ItemGroup>
<ItemGroup>
	<Using Include="System.Console" Static="true" />
ItemGroup>
Project>

关注:

<ItemGroup>
<ProjectReference Include="../PacktLibraryNetStandard2/PacktLibraryNetStandard2.csproj" />
ItemGroup>

然后构建即可:

dotnet build

在控制台项目 PeopleApp 中添加一个类文件 Program.Helpers.cs,内容如下:

using System.Globalization; // To use CultureInfo.

partial class Program
{
	private static void ConfigureConsole(
		string culture = "en-US",
		bool useComputerCulture = false,
		bool showCulture = true)
	{
		OutputEncoding = System.Text.Encoding.UTF8;

		if (!useComputerCulture)
		{
			CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(culture);
		}

		if (showCulture)
		{
			System.Console.WriteLine($"Current culture: {CultureInfo.CurrentCulture.DisplayName}.");
		}
	}
}

然后再 Program.cs 中实例化类,并调用上述方法:

using Packt.Shared;

ConfigureConsole();

// Alternatives:
// ConfigureConsole(useComputerCulture: true); // Use your culture.
// ConfigureConsole(culture: "fr-FR"); // Use French culture.

// Person bob = new Person(); // C# 1 or later.
// var bob = new Person(); // C# 3 or later.
Person bob = new(); // C# 9 or later.
WriteLine(bob); // Implicit call to ToString().
// WriteLine(bob.ToString()); // Does the same thing.

运行结果:

Current culture: 英语(美国).
Packt.Shared.Person

为什么一个空的类型都有一个 ToString 的方法?

继承自 System.Object

尽管我们的自定义类没有显式指定继承自哪一个类型,但是所有的类型最终都直接或间接地继承自一个名为 System.Object 的特殊类型。该类型的 ToString 方法实现输出完整的名称空间和类型名。

如果想指明一个类型显式地继承:

public class Person: System.Object

如果一个 类B 继承自 类A ,我们称 类A 为基类(base 或 superclass),而 类B 为派生类或子类(the derived or subclass)。

其实 System.Object 等同于 C#关键字 object

应该知道所有的类都隐式地直接继承或间接继承自 object

使用别名避免名称空间冲突

Avoiding a namespace conflict with a using alias

可能有两个名称空间包含相同的类型名称,导入这两个名称空间会导致歧义。例如,JsonOptions 存在于多个 Microsoft 定义的命名空间中。如果你使用错误的配置来配置 JSON 序列化,那么它将被忽略,你会很困惑为什么!

一个例子:

// In the file, France.Paris.cs
namespace France
{
    public class Paris
    {
    }
}
// In the file, Texas.Paris.cs
namespace Texas
{
	public class Paris
    {
	}
}

// In the file, Program.cs
using France;
using Texas;
Paris p = new();

我们编译时,会报错:

Error CS0104: 'Paris' is an ambiguous reference between 'France.Paris' and 'Texas.Paris'

我们可以定义别名,如下所示:

using France; // To use Paris.
using Tx = Texas; // Tx 为名称空间地别名,但是并未导入
Paris p1 = new(); // Creates an instance of France.Paris.
Tx.Paris p2 = new(); // Creates an instance of Texas.Paris.

使用别名重命名类型

当我们想要重命名一个类型时,也可以使用别名。类似于 Python 的 as ,可以减少指定名称空间时的冗长名字:

using Env = System.Environment;
WriteLine(Env.OSVersion);
WriteLine(Env.MachineName);
WriteLine(Env.CurrentDirectory);

自 C#12 之后,我们可以给任意类型别名。这意味着可以重命名已存在的类型或给一个无命名*(unnamed)的类型如 tuple 一个类型名。

在字段中存储数据

Storing data in fields

定义字段

Defining fields

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
}

关于出生日期的类型,有许多选择。.NET6 引入了 DateOnly 类型,该类型只保存日期而没有时间。DateTime 存储日期和时间,但它在本地时间和 UTC 时间之间有所不同。最佳选择是 DateTimeOffset,该类型存储偏离 Universal Coordinated Time (UTC)(与时区有关) 的日期、时间和小时。

选择合适的。

字段的类型

从 C# 8 开始,编译器能够在引用类型(如字符串)具有 null 值并因此可能引发 NullReferenceException 时向您发出警告。从 .NET 6 开始,SDK 默认启用这些警告。您可以在字符串类型后加上问号 ? 来表示您接受这一点,然后警告就会消失。

成员访问修饰符

Member access modifiers

封装的一部分含义就是指控制成员能否被其他代码可见。

值得注意的是,默认的成员访问修饰符是 private

有四种成员访问修饰符( member access modifier )关键字,以及两种访问修饰符关键字的组合可以用于一个类的成员(字段或方法)。成员访问修饰符应用于独立个体。它们与应用于整个类型的类型访问修饰符相似但独立。

六种可能的组合如下所示:

  • 私有 private :默认,该成员只能在类型内部被访问。
  • 内部 interval:该成员可以在类型内部或相同程序集内的其他任意类型访问。
  • 保护 protected:该成员可以在类型内部或派生类型中被访问。
  • 公有 public:该类型可以被任意访问。
  • 内部保护 internal protected:该成员可以在类型内部、相同程序集内的其他任意类型,以及派生类型内访问。理解为 internal_or_protected
  • 私有保护 private protected:该成员可以在类型内部、相同程序集内的派生类型内访问。理解为 internel_and_proteced,该组合自 C#7.2 引入。

良好实践:将访问修饰符之一显式应用于所有类型成员,即使您想对成员使用私有的隐式访问修饰符。此外,字段通常应该是私有的或受保护的,然后您应该创建公共属性(property)来获取或设置字段值。这是因为该属性随后控制访问。

设置或访问字段值

Setting and outputting field values

代码:

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 标准时间

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。

运行结果:

Bob Smith was born on Wednesday, December 22, 1965.

使用初始化语法设置字段值

Setting field values using object initializer syntax

还可以使用带有大括号的简写对象初始化语法来初始化字段,该语法是在 C# 3 中引入的:

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);

运行结果:

Alice Jones was born on 3/7/1998.

良好实践:使用命名参数来传递参数,这样值的含义就更清楚,特别是对于像 DateTimeOffset 这样的类型,其中有一堆相继的数字。

使用枚举类型存储值

Storing a value using an enum type

定义枚举。我们在类库项目中增加一个新的文件 WondersOfTheAncientWorld.cs,并在其中定义枚举:

namespace Packt.Shared;
public enum WondersOfTheAncientWorld
{
    GreatPyramidOfGiza,
    HangingGardensOfBabylon,
    StatueOfZeusAtOlympia,
    TempleOfArtemisAtEphesus,
    MausoleumAtHalicarnassus,
    ColossusOfRhodes,
    LighthouseOfAlexandria
}

使用枚举。在类库项目的 Person.cs 中,使用枚举类型来定义一个字段:

public WondersOfTheAncientWorld FavoriteAncientWonder;

在引用该类库项目的控制台应用中使用该字段:

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);

运行结果:

Bob Smith's favorite wonder is StatueOfZeusAtOlympia. Its integer is 2.

为了提高效率,枚举值在内部存储为 int。 int 值从 0 开始自动分配,因此我们枚举中的第三世界奇迹的值为 2。您可以将枚举中没有列出的 int 值赋给枚举变量。它们将输出为 int 值而不是名称,因为找不到匹配项。

使用一个枚举类型存储多个值

Storing multiple values using an enum type

如果我们想表达多个枚举值,可以使用类似C语言的位运算的技术,而不是使用一个列表。

称之为 enum flags。

我们需要使用 [Flags] 特性来修饰枚举定义,并制定每个枚举的具体值与类型。我们将之前的枚举定义修改如下:

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
}

我们为每种选择提供了显式的值,这些值在具体的 bit 位上不会重叠。

我们还应该用 System.Flags 属性来装饰 enum 类型,他可以自动匹配为 逗号分隔的、枚举名称组成的字符串,而不是返回 int 值。

通常,枚举类型在内部使用 int 变量,但由于我们不需要那么大的值,因此可以通过指定类型来避免内存浪费。

该枚举可以这么用:

bob.FavoriteAncientWonder =
	WondersOfTheAncientWorld.HangingGardensOfBabylon
	| WondersOfTheAncientWorld.MausoleumAtHalicarnassus;
// bob.BucketList = (WondersOfTheAncientWorld)18;
WriteLine($"{bob.Name}'s bucket list is {bob.BucketList}.");

运行输出:

Bob Smith's bucket list is HangingGardensOfBabylon, MausoleumAtHalicarnassus.

使用集合存储多个值

Storing multiple values using collections

在类库的 Person.cs 文件中添加列表字段:

public List<Person> Children = new();

注意这里要申请内存进行实例化,否则该字段默认初始化为 null

理解泛化集合

Understanding generic collections

这里注意到类似与 C++ 模板的语法,这是 C#2 引入的。这是一个使集合强类型化(Strongly typed)的奇特术语,也就是说,编译器明确知道集合中可以存储什么类型的对象。泛型可以提高代码的性能和正确性。

强类型(Strongly typed)与静态类型(statically typed)具有不同的含义。旧的 System.Collection 类型是静态类型的以包含弱类型(weakly typed)的 System.Object 项。较新的 System.Collection.Generic 类型是静态类型以包含强类型(strongly typed) 实例。

讽刺的是,术语泛型意味着我们可以使用更具体的静态类型!

我们可以这样使用上述的列表字段:

// 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($"{bob.Name} has {bob.Children.Count} children:");
for (int childIndex = 0; childIndex < bob.Children.Count; childIndex++)
{
	WriteLine($"> {bob.Children[childIndex].Name}");
}

静态字段

Making a field static

有时想要定义一个所有实例都共享的字段,即静态成员。在成员定义前加上 static 关键字即可。

注意:字段并不是唯一可以是静态的成员。构造函数、方法、属性和其他成员也可以是静态的。

常量字段

Making a field constant
使用 const 关键字定义一个从不会改变的字段,并在编译时期赋予一个字面值。相关错误将会在编译器产生错误。

良好实践:由于两个重要原因,常量并不总是最佳选择:该值必须在编译时已知,并且必须可以表示为文字字符串、布尔值或数值。对 const 字段的每个引用都会在编译时替换为文字值,因此,如果该值在未来版本中发生更改,并且您不重新编译任何引用该字段的程序集来获取新值,则该值在其他项目中不会反映出改变。

只读字段

Making a field read-only

通常,对于一个不会改变的字段有一个更好的选择,即将其设为只读属性。使用的关键字是 readonly

良好实践:使用只读字段而不是常量字段有两个重要原因:该值可以在运行时计算或加载,并且可以使用任何可执行语句来表达。因此,可以使用构造函数或字段赋值来设置只读字段。对只读字段的每个引用都是实时引用,因此将来的任何更改都将由调用代码正确反映。

实例化时需要设置的字段

Requiring fields to be set during instantiation

C#11 引入了 required 修饰符。将其用于一个字段(field)或属性(property)时,编译器将确保在实例化时设置对应的字段或属性。这需要至少 .NET7 及之后的版本。

如果字段是可空的,那么加上 required 修饰符后即使要设置为空,也要显式地赋 null

使用构造器初始化字段

Initializing fields with constructors

字段通常需要在运行时初始化您可以在构造函数中执行此操作,当使用 new 关键字创建类的实例时将调用构造函数。构造函数在使用该类型的代码设置任何字段之前执行。

例如:

// Read-only fields: Values that can be set at runtime.
#region Fiedls: part of fields.
public readonly string HomePlanet = "Earth";
public readonly DateTime Instantiated;
#endregion
    
#region Constructors: Called when using new to instantiate a type.
public Person()
{
    // Constructors can set default values for fields
    // including any read-only fields like Instantiated.
    Name = "Unknown";
    Instantiated = DateTime.Now;
}
#endregion

一个类型可以有多个构造函数,我们还可以再定义第二个构造函数,并加上一些参数:

public Person(string initialName, string homePlanet)
{
    Name = initialName;
    HomePlanet = homePlanet;
    Instantiated = DateTime.Now;
}

可以使用构造函数来设置那些 required 字段。

例如:

public class Book
{
    // Needs .NET 7 or later as well as C# 11 or later.
    public required string? Isbn;
    public required string? Title;
    // Works with any version of .NET.
    public string? Author;
    public int PageCount
    
    // Constructor for use with object initializer syntax.
    public Book() { }
    // Constructor with parameters to set required fields.
    public Book(string? isbn, string? title)
    {
        Isbn = isbn;
        Title = title;
	}
}

注意这里的对象初始化语法(object initializer syntax)。我们接着看以下代码:

Book book = new(isbn: "978-1803237800",
    title: "C# 12 and .NET 8 - Modern Cross-Platform Development Fundamentals")
{
    Author = "Mark J. Price",
    PageCount = 821
};

这里先使用了构造函数然后使用 对象初始化语法 设置了其他 非 reuqired 属性。

但是注意,这样还会看到编译器错误,因为编译器无法自动判断调用构造函数将设置两个必需的属性。

我们需要导入代码分析相关的名称空间,来告诉编译器这一信息:

using System.Diagnostics.CodeAnalysis; // To use [SetsRequiredMembers].
namespace Packt.Shared;
public class Book
{
    public Book() { } // For use with initialization syntax.
    [SetsRequiredMembers]
    public Book(string isbn, string title)

现在就不会出现编译器错误了。

More Information: You can learn more about required fields and how to set them using
a constructor at the following link: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/required.

使用方法和元组

就像 C++ 一样。

C# 也可以重载函数,一样有函数签名(method signature)的概念。

类似地,也有默认参数(可选参数,optional parameters)

.

命名参数值

不同的是,C# 可以在调用函数时命名参数值。(命名传递)

Naming parameter values when calling methods

调用方法时,可选参数通常与命名参数结合使用,因为命名参数允许以与声明方式不同的顺序传递值。

例子

public string OptionalParameters(string command = "Run!", double number = 0.0, bool active = true)
{
    return string.Format(
        format: "command is {0}, number is {1}, active is {2}",
        arg0: command,
        arg1: number,
        arg2: active);
}

调用时可以:

WriteLine(bob.OptionalParameters());
WriteLine(bob.OptionalParameters("Jump!", 98.5));
WriteLine(bob.OptionalParameters(number: 52.7, command: "Hide!"));
// 混合调用方式
WriteLine(bob.OptionalParameters("Poke!", active: false));

第三个使用命名参数并没有按照参数声明的顺序传递;第四行调用第一个参数使用了位置传递的方式,第二个参数使用了命名传递。

良好实践:虽然您可以混合命名参数值和位置参数值,但大多数开发人员更喜欢阅读在同一方法调用中使用统一调用方式的代码。

混合可选和必须参数

Mixing optional and required parameters

与C++类似的是,函数定义时,必须参数必须在可选参数之前。

例如:

public string OptionalParameters(int count,
    string command = "Run!",
    double number = 0.0, bool active = true)

调用时,必须传入第一个参数。但是,我们仍可以使用命名传递的方式传递参数(位置可变了,不一定非要在可选参数之前):

WriteLine(bob.OptionalParameters(3));
WriteLine(bob.OptionalParameters(3, "Jump!", 98.5));
WriteLine(bob.OptionalParameters(3, number: 52.7, command: "Hide!"));
WriteLine(bob.OptionalParameters(3, "Poke!", active: false));
bob.OptionalParameters(number: 52.7, command: "Hide!",count: 3).

控制如何传递参数

Controlling how parameters are passed

有几种方式控制如何传入参数:

  • 值传递(默认),被视作是 in-only。
  • 作为一个输出参数(out),被视作是 out-only。out 参数不能有默认值,并且不能保持未初始化状态。它们必须在方法内部设置;否则编译器会报错。(其实也是引用)
  • 引用传递(ref),被视作是 in-and-out。ref 参数也不能有默认值,但是他们不一定必须在方法设置。
  • 作为一个输入参数(in),被视作是 read-only 的引用参数。in 参数的值无法更改。

在 C#7 之后,因为 out 参数总会被替换,所以可以在调用方法传递参数的同时定义该变量,可以看之后的例子。

例子:

public void PassingParameters(int w, in int x, ref int y, out int z)
{
    // out parameters cannot have a default and they
    // must be initialized inside the method.
    z = 100;
    // Increment each parameter except the read-only x.
    w++;
    // x++; // Gives a compiler error!
    y++;
    z++;
    WriteLine($"In the method: w={w}, x={x}, y={y}, z={z}");
}

int a = 10;
int b = 20;
int c = 30;
int d = 40;
WriteLine($"Before: a={a}, b={b}, c={c}, d={d}");
bob.PassingParameters(a, b, ref c, out d);
WriteLine($"After: a={a}, b={b}, c={c}, d={d}");

int e = 50;
int f = 60;
int g = 70;
WriteLine($"Before: e={e}, f={f}, g={g}, h doesn't exist yet!");
// Simplified C# 7 or later syntax for the out parameter.
bob.PassingParameters(e, f, ref g, out int h);
WriteLine($"After: e={e}, f={f}, g={g}, h={h}");

输出:

Before: a=10, b=20, c=30, d=40
In the method: w=11, x=20, y=31, z=101
After: a=10, b=20, c=31, d=101
Before: e=50, f=60, g=70, h doesn't exist yet!
In the method: w=51, x=60, y=71, z=101
After: e=50, f=60, g=71, h=101

引用返回

Understanding ref returns

在 C# 7 或更高版本中,ref 关键字不仅仅用于将参数传递给方法;它还可以应用于返回值。这允许外部变量引用内部变量并在方法调用后修改其值。

这在高级场景中可能很有用,例如,将占位符传递到大数据结构中,但这超出了本书的范围。如果您有兴趣了解更多信息,可以阅读以下链接:https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/ref#reference-return-values.

使用元组返回多个值

Combining multiple returned values using tuples

元组是将两个或多个值组合成一个单元的有效方法。

直到 2017 年的 C# 7,C# 才添加了对使用括号字符 () 的元组的语言语法支持,同时 .NET 添加了新的 System.ValueTuple 类型,该类型在一些常见场景中比旧的 .NET System.Tuple类型更高效。

例子:

定义一个返回元组的函数(方法):

// Method that returns a tuple: (string, int).
public (string, int) GetFruit()
{
	return ("Apples", 5);
}

使用该函数获取返回值后,元组的元素自动用 Item1Item2 表示:

(string, int) fruit = bob.GetFruit();
WriteLine($"{fruit.Item1}, {fruit.Item2} there are.");

为元组的字段命名

Naming the fields of a tuple

为了访问元组的字段,默认的名称为 Item1Item2等。

也可以显式指定字段名称:

// Method that returns a tuple with named fields.
public (string Name, int Number) GetNamedFruit()
{
	return (Name: "Apples", Number: 5);
}

使用:

var fruitNamed = bob.GetNamedFruit();
WriteLine($"There are {fruitNamed.Number} {fruitNamed.Name}.");

这里我们使用 var 来代替完整的元组类型,上述第一行代码等价于:

string Name, int Number) fruitNamed = bob.GetNamedFruit();

如果直接从其他对象构建一个元组(而不是作为函数返回),我们可以使用 C#7.1 引入的特征:元组命名引用(tuple name inference)。

var thing1 = ("Neville", 4);
WriteLine($"{thing1.Item1} has {thing1.Item2} children.");
var thing2 = (bob.Name, bob.Children.Count);
WriteLine($"{thing2.Name} has {thing2.Count} children.");

在 C#7 时,这两个字段应该使用 Item1Item2 表示,在 C#7.1及之后,上述代码中 thing2 的这两个字段会自动推断并命名为 Name Count

元组别名

Aliasing tuples

在 C#12 之后,也可以像 C++ 那样使用 using 对类型设置别名:

// Aliasing a tuple type.
using UnnamedParameters = (string, int); 
// Aliasing a tuple type with parameter names.
using Fruit = (string Name, int Number);

解构元组

Deconstructing tuples

(类似于 C++ 的结构化绑定,但是可以指定类型)

还可以将元组解构为单独的变量。解构声明与命名字段元组具有相同的语法,但没有元组的命名变量,如以下代码所示:

// 使用两个命名字段的元组存储返回值
(string name, int number) namedFields = bob.GetNamedFruit();
// You can then access the named fields.
WriteLine($"{namedFields.name}, {namedFields.number}");
// 直接将返回的元组解构为两个变量
(string name, int number) = bob.GetNamedFruit();
// You can then access the separate variables.
WriteLine($"{name}, {number}");

使用元组解构其他类型

Deconstructing other types using tuples

并不是只有元组类型才能被解构。任何含有 Deconstruct 方法的类型都可以将对象分解为不同的部分。Deconstruct 方法没有返回值(void),分解的每个部分通过 out 参数传递。

例子(之前的 Person 类):

// Deconstructors: Break down this object into parts.
public void Deconstruct(out string? name,
    out DateTimeOffset dob)
{
    name = Name;
    dob = Born;
}
public void Deconstruct(out string? name,
	out DateTimeOffset dob,
	out WondersOfTheAncientWorld fav)
{
    name = Name;
    dob = Born;
    fav = FavoriteAncientWonder;
}

使用时:

//隐式调用 Deconstruct 方法
var (name1, dob1) = bob; 
WriteLine($"Deconstructed person: {name1}, {dob1}");
var (name2, dob2, fav2) = bob;
WriteLine($"Deconstructed person: {name2}, {dob2}, {fav2}");

当将对象分配给元组变量时,它会被隐式调用。

使用本地函数实现功能

Implementing functionality using local functions

C# 7 引入的一个语言特性是定义本地函数(local function)。

本地函数是等同于本地变量的方法,也就是说它们只能在定义它们的方法内部被访问。

在其他语言中被称为嵌套或内部函数(nested or inner functions)。

本地函数可以在一个方法的内部任意地方定义。

例子:

甚至可以在返回语句之后定义本地函数:

// Method with a local function.
public static int Factorial(int number)
{
    if (number < 0)
    {
        throw new ArgumentException(
        $"{nameof(number)} cannot be less than zero.");
    }
    return localFactorial(number);
    int localFactorial(int localNumber) // Local function.
    {
        if (localNumber == 0) return 1;
        return localNumber * localFactorial(localNumber - 1);
    }
}

使用 partial 分离类

Splitting classes using partial

将一个类在不同的文件进行定义,使用 partial 关键字。

注意,类的每个部分都要使用该关键字。

使用属性或索引器控制访问

Controlling access with properties and indexers

**属性(property)**只是一个方法(或一对方法),当您想要获取或设置值时,它的行为和看起来像一个字段,但它的行为像一个方法,从而简化了语法并启用了功能(functionality),例如当您设置并获取值时进行验证和计算。

字段(field)和属性(property)之间的根本区别在于字段为数据提供内存地址。您可以将该内存地址传递给外部组件,例如 Windows API C 风格的函数调用,然后它可以修改数据。属性不为其数据提供内存地址,而是提供了更多控制。您所能做的就是要求属性获取或设置数据。然后,该属性执行语句并可以决定如何响应,包括拒绝请求!

定义只读属性

Defining read-only properties

只读属性只有 get 实现。

例子:

#region Properties: Methods to get and/or set data or state 
// A readonly property defined using C# 1 to 5 syntax.
public string Origin
{
    get
    {
        return string.Format("{0} was born on {1}.",
        	arg0: Name, arg1: HomePlanet);
    }
}
//  C# 6 or later 定义只读属性的语法:
// lambda expression body syntax.
public string Greeting => $"{Name} says 'Hello!'";
public int Age => DateTime.Today.Year - Born.Year;
#endregion

使用:

Person sam = new()
{
    Name = "Sam",
    Born = new(1969, 6, 25, 0, 0, 0, TimeSpan.Zero)
};
WriteLine(sam.Origin);
WriteLine(sam.Greeting);
WriteLine(sam.Age);

运行结果:

Sam was born on Earth
Sam says 'Hello!'
54

定义可设置属性

Defining settable properties

要定义可设置属性,必须使用旧语法提供一对函数,分别为 getset

gettersetter

例如:

// A read-write property defined using C# 3 auto-syntax.
public string? FavoriteIceCream { get; set; }

尽管您没有手动创建一个字段来存储该数据,编译器将自动创建。

有时,您需要对设置属性时发生的情况进行更多控制。在这种情况下,您必须使用更详细的语法并手动创建私有字段来存储属性的值。

添加语句来定义私有字符串字段(称为支持字段)

// A private backing field to store the property value.
private string? _favoritePrimaryColor;

并根据此私有字段定义属性:

// A public property to read and write to the field.
public string? FavoritePrimaryColor
{
    get
    {
    	return _favoritePrimaryColor;
    }
    set
    {
    	switch (value?.ToLower())
        {
        case "red":
        case "green":
        case "blue":
            _favoritePrimaryColor = value;
        break;
        default:
            throw new ArgumentException(
                $"{value} is not a primary color. " +
                "Choose from: red, green, blue.");
        }
	}
}

良好实践:避免向 getter 和 setter 添加过多代码。这可能表明您的设计存在问题。考虑添加私有方法,然后在 set 和 get 方法中调用这些方法以简化实现。

sam.FavoriteIceCream = "Chocolate Fudge";
WriteLine($"Sam's favorite ice-cream flavor is {sam.FavoriteIceCream}.");
string color = "Red";
try
{
    sam.FavoritePrimaryColor = color;
    WriteLine($"Sam's favorite primary color is {sam.
    FavoritePrimaryColor}.");
}
catch (Exception ex)
{
    WriteLine("Tried to set {0} to '{1}': {2}",
    nameof(sam.FavoritePrimaryColor), color, ex.Message);
}

养成习惯,记得异常处理。

良好实践:当您想要在读取或写入字段期间执行语句而不使用方法对(如 GetAge 和 SetAge)时,请使用属性而不是字段。

限制标志枚举值

Limiting flags enum values

对之前的公有的枚举字段,更改为设置一个私有的枚举字段和一个公有属性,如下所示:

private WondersOfTheAncientWorld _favoriteAncientWonder;
public WondersOfTheAncientWorld FavoriteAncientWonder
{
	get { return _favoriteAncientWonder; }
	set
    {
        string wonderName = value.ToString();
        if (wonderName.Contains(','))
        {
            throw new ArgumentException(
            message: "Favorite ancient wonder can only have a single enum value.",
            paramName: nameof(FavoriteAncientWonder));
        }
        if (!Enum.IsDefined(typeof(WondersOfTheAncientWorld), value))
        {
            throw new ArgumentException(
            $"{value} is not a member of the WondersOfTheAncientWorld enum.",
            paramName: nameof(FavoriteAncientWonder));
        }
		_favoriteAncientWonder = value;
		}
}

我们可以通过仅检查原始枚举中是否定义了该值来简化验证,因为 IsDefined 对于多个值和未定义的值返回 false。但是,我想为多个值显示不同的异常,因此我将使用以下事实:格式化为字符串的多个值将在名称列表中包含逗号。这也意味着我们必须在检查值是否已定义之前检查多个值。但是注意:逗号分隔列表是将多个枚举值表示为字符串的方式,但不能使用逗号来设置多个枚举值。你应该使用 | (按位或)。

定义索引器

Defining indexers

索引器(Indexers)允许使用数组语法来访问一个属性,例如 string 允许使用索引器访问每个字符,如:

string alphabet = "abcdefghijklmnopqrstuvwxyz";
char letterF = alphabet[5]; // 0 is a, 1 is b, and so on.

索引器的参数并不一定是整数,也可以是字符串或其他类。

例如:

#region Indexers: Properties that use array syntax to access them.
public Person this[int index]
{
    get
    {
    	return Children[index]; // Pass on to the List indexer.
    }
    set
    {
    	Children[index] = value;
    }
}

// A read-only string indexer.
public Person this[string name]
{
	get
    {
		return Children.Find(p => p.Name == name);
	}
}
#endregion

使用:

sam.Children.Add(new() { Name = "Charlie",Born = new(2010, 3, 18, 0, 0, 0, TimeSpan.Zero) });
sam.Children.Add(new() { Name = "Ella",Born = new(2020, 12, 24, 0, 0, 0, TimeSpan.Zero) });

// Get using Children list.
WriteLine($"Sam's first child is {sam.Children[0].Name}.");
WriteLine($"Sam's second child is {sam.Children[1].Name}.");
// Get using the int indexer.
WriteLine($"Sam's first child is {sam[0].Name}.");
WriteLine($"Sam's second child is {sam[1].Name}.");
// Get using the string indexer.
WriteLine($"Sam's child named Ella is {sam["Ella"].Age} years old.");

运行结果:

Sam's first child is Charlie.
Sam's second child is Ella.
Sam's first child is Charlie.
Sam's second child is Ella.
Sam's child named Ella is 3 years old.

使用对象进行模式匹配

Pattern matching with objects

在此示例中,我们将定义一些代表航班上各种类型乘客的类,然后我们将使用具有模式匹配的 switch 表达式来确定他们的航班费用

代码:

namespace Packt.Shared;
public class Passenger
{
	public string? Name { get; set; }
}

public class BusinessClassPassenger : Passenger
{
	public override string ToString()
    { 
    	return $"Business Class: {Name}";
    }
}
public class FirstClassPassenger : Passenger
{
    public int AirMiles { get; set; }
    public override string ToString()
    {
    	return $"First Class with {AirMiles:N0} air miles: {Name}";
    }
}

public class CoachClassPassenger : Passenger
{
    public double CarryOnKG { get; set; }
    public override string ToString()
    {
    	return $"Coach Class with {CarryOnKG:N2} KG carry on: {Name}";
    }
}

定义一个对象数组,包含五个不同类型和属性值的乘客,然后枚举他们,输出他们的航班费用,如下代码所示:

// An array containing a mix of passenger types.
Passenger[] passengers = {
	new FirstClassPassenger { AirMiles = 1_419, Name = "Suman" },
	new FirstClassPassenger { AirMiles = 16_562, Name = "Lucy" },
	new BusinessClassPassenger { Name = "Janice" },
	new CoachClassPassenger { CarryOnKG = 25.7, Name = "Dave" },
	new CoachClassPassenger { CarryOnKG = 0, Name = "Amit" },
};
foreach (Passenger passenger in passengers)
{
	decimal flightCost = passenger switch
    {
        FirstClassPassenger p when p.AirMiles > 35_000 => 1_500M,
        FirstClassPassenger p when p.AirMiles > 15_000 => 1_750M,
        FirstClassPassenger _ => 2_000M,
        BusinessClassPassenger _ => 1_000M,
        CoachClassPassenger p when p.CarryOnKG < 10.0 => 500M,
        CoachClassPassenger _ => 650M,
        _ => 800M
    };
	WriteLine($"Flight costs {flightCost:C} for {passenger}");
}

值得注意的是:

  • 为了访问对象的属性,我们需要命名一个局部变量,如上面的 p,可以用于后面的表达式。
  • 如果仅仅是匹配类型,我们使用 _ 抛弃该局部变量。
  • switch 表达式的默认分支使用 _ 表示

运行结果:

Flight costs $2,000.00 for First Class with 1,419 air miles: Suman
Flight costs $1,750.00 for First Class with 16,562 air miles: Lucy
Flight costs $1,000.00 for Business Class: Janice
Flight costs $650.00 for Coach Class with 25.70 KG carry on: Dave
Flight costs $500.00 for Coach Class with 0.00 KG carry on: Amit

C#9 之后的增强模式匹配

前面的例子可以在 C#8 工作。

C# 9 及之后,我们不需要使用下划线抛弃局部变量来仅进行类型匹配。

代码如下:

decimal flightCost = passenger switch
{
    // C# 9 or later syntax
    FirstClassPassenger p => p.AirMiles switch
    {
        > 35_000 => 1_500M,
        > 15_000 => 1_750M,
        _ => 2_000M
    },
        
    BusinessClassPassenger => 1_000M,
    CoachClassPassenger p when p.CarryOnKG < 10.0 => 500M,
    CoachClassPassenger => 650M,
	_ => 800M
};

您还可以将关系模式(elational pattern)与属性模式(property pattern)结合使用,以避免嵌套 switch 表达式,如以下代码所示:

FirstClassPassenger { AirMiles: > 35000 } => 1500M,
FirstClassPassenger { AirMiles: > 15000 } => 1750M,
FirstClassPassenger => 2000M,

使用记录类型

Working with record types

在我们深入研究新的记录语言功能之前,让我们先看看 C# 9 及更高版本的其他一些相关新功能。

仅初始化属性

Init-only properties

在本章中,您已经使用对象初始化语法来实例化对象并设置初始属性。这些属性也可以在实例化后更改。
有时,您希望将属性视为只读字段,以便可以在实例化期间而不是实例化之后设置它们。换句话说,它们是不可变的。 init 关键字可以实现这一点。它可以用来代替属性定义中的 set 关键字。
由于这是 .NET Standard 2.0 不支持的语言功能。

namespace Packt.Shared;
public class ImmutablePerson
{
    public string? FirstName { get; init; }
    public string? LastName { get; init; }
}

使用:

ImmutablePerson jeff = new()
{
    FirstName = "Jeff",
	LastName = "Winger"
};
jeff.FirstName = "Geoff";//Error!

最后一行将会导致编译器报错。

注意:如果没有在对象初始值设定项中设置 init-only 属性,那也无法在初始化后再去设置它。如果您需要强制设置某个属性,请用 required 关键字。

定义 record 类型

init-only 属性为 C# 提供了一些不变性(immutability)。您可以通过使用 record 类型进一步深化该概念。这些是通过使用 record 关键字而不是(或同时) class 关键字来定义的。

这可以使整个对象不可变(immutable),并且在比较时它就像一个值(这将在之后讨论)。

不可变记录不应具有在实例化后发生更改的任何状态(属性和字段)。

相反,我们的想法是从现有记录创建新记录。新记录的状态已更改。这称为非破坏性突变(non-destructive mutation)。为此,C# 9 引入了 with 关键字:

例子:

public record ImmutableVehicle
{
    public int Wheels { get; init; }
    public string? Color { get; init; }
    public string? Brand { get; init; }
}

使用:

ImmutableVehicle car = new()
{
    Brand = "Mazda MX-5 RF",
    Color = "Soul Red Crystal Metallic",
    Wheels = 4
};
ImmutableVehicle repaintedCar = car
	with { Color = "Polymetal Grey Metallic" };

WriteLine($"Original car color was {car.Color}.");
WriteLine($"New car color is {repaintedCar.Color}.");

运行结果:

Original car color was Soul Red Crystal Metallic.
New car color is Polymetal Grey Metallic.

即使释放掉 car 的内存,repaintedCar 也依然存在。(两者相互独立)

记录类型的相等性

Equality of record types

记录类型的另一个重要特性是其相等性。具有相同属性值的两条记录被视为相等。这听起来可能并不令人惊讶,但如果您使用普通类而不是记录,那么它们将不会被认为是相等的。

让我们来看看

namespace Packt.Shared;
public class AnimalClass
{
	public string? Name { get; set; }
}
public record AnimalRecord
{
	public string? Name { get; set; }
}

使用:

AnimalClass ac1 = new() { Name = "Rex" };
AnimalClass ac2 = new() { Name = "Rex" };
WriteLine($"ac1 == ac2: {ac1 == ac2}");

AnimalRecord ar1 = new() { Name = "Rex" };
AnimalRecord ar2 = new() { Name = "Rex" };
WriteLine($"ar1 == ar2: {ar1 == ar2}");

运行:

ac1 == ac2: False
ar1 == ar2: True

类实例只有在他们字面上为同一个对象时才相等,即它们的内存地址相等。之后还会介绍类型相等的其他内容。

记录中的位置数据成员

Positional data members in records

可以使用位置数据成员(positional data members)简化定义记录的语法。

有时您可能更愿意提供带有位置参数的构造函数,而不是使用带有大括号的对象初始化语法,正如您在本章前面所看到的那样。您还可以将其与解构函数结合使用,将对象拆分为各个部分,如以下代码所示:

代码:

public record ImmutableAnimal
{
    public string Name { get; init; }
    public string Species { get; init; }
    public ImmutableAnimal(string name, string species)
    {
        Name = name;
        Species = species;
    }
	public void Deconstruct(out string name, out string species)
    {
        name = Name;
        species = Species;
    }
}

其实属性、构造器、解构器都可以通过简化语法自动生成:

使用简化语法的记录定义如下:

// Simpler syntax to define a record that auto-generates the
// properties, constructor, and deconstructor.
public record ImmutableAnimal(string Name, string Species);

使用:

ImmutableAnimal oscar = new("Oscar", "Labrador");
var (who, what) = oscar; // Calls the Deconstruct method.
WriteLine($"{who} is a {what}.");

运行:

Oscar is a Labrador.

c# 10 支持创建结构记录(struct records),将在之后介绍。

More Information: There are many more ways to use records in your projects. I recom-mend that you review the official documentation at the following link: https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/tutorials/records.

为类定义主构造函数

Defining a primary constructor for a class

随 C# 12 引入,您可以定义一个构造函数作为类定义的一部分。这称为主构造函数。其语法与记录中的位置数据成员相同,但行为略有不同。

传统的,我们将类定义和构造器定义分开,如下所示:

public class Headset // Class definition.
{
	// Constructor.
    public Headset(string manufacturer, string productName)
    {
    // You can reference manufacturer and productName parameters in the constructor and the rest of the class.
    }
}

使用类主构造函数,您可以将两者组合成更简洁的语法,如以下代码所示:

public class Headset(string manufacturer, string productName);

不同于记录的简化定义方法,这个主构造函数的参数不会自动变为公有的属性。

我们还需要显式定义这两个属性:

public class Headset(string manufacturer, string productName)
{
    public string Manufacturer { get; set; } = manufacturer;
    public string ProductName { get; set; } = productName;
}

我们还可以定义默认构造器,只需要"委托"给参数给主构造器:

public class Headset(string manufacturer, string productName)
{
    public string Manufacturer { get; set; } = manufacturer;
    public string ProductName { get; set; } = productName;
    // 默认无参数构造函数调用主构造函数。
    public Headset() : this("Microsoft", "HoloLens") { }
}

使用:

Headset holo = new();
WriteLine($"{holo.ProductName} is made by {holo.Manufacturer}.");

Headset mq = new() { Manufacturer = "Meta", ProductName = "Quest 3" };
WriteLine($"{mq.ProductName} is made by {mq.Manufacturer}.");

运行:

Vision Pro is made by Apple.
HoloLens is made by Microsoft.
Quest 3 is made by Meta

More Information: You can learn more about primary constructors for classes and structs at the following link: https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/tutorials/primary-constructors.

你可能感兴趣的:(c#,学习,笔记)