C# 类

1. 什么是类?

在C#中,类(Class)是一种重要的概念,它是面向对象编程的基本构造块之一。类用于定义对象的结构和行为,并作为对象的模板或蓝图。

类具有以下特点:

  • 对象的模板:类是用于创建对象的模板。它定义了对象的属性(字段)和行为(方法),以及可能的初始状态(构造函数)和销毁操作(析构函数)。

  • 封装性:类提供了封装机制,将数据和相关的操作(方法)封装在一起。这样可以隐藏内部实现细节,只暴露对外的接口,提高代码的可维护性和安全性。

  • 继承性:C#中的类支持继承关系。一个类可以派生出一个或多个子类(派生类),从而继承父类(基类)的属性和行为。通过继承,子类可以扩展、修改或重写父类的功能。

  • 多态性:多态是面向对象编程的重要特性之一。C#中的类通过继承和接口实现多态性,允许使用基类或接口引用来引用具体的子类对象,并根据实际对象类型调用相应的方法。

  • 实例化:类本身只是一个模板或类型定义,并不能直接使用。要使用类创建对象,需要实例化类,生成对象的实例。

通过定义和使用类,我们可以在C#中创建具有特定属性和行为的自定义数据类型。类提供了一种组织和封装代码的方式,使得代码更加模块化、可扩展和易于维护。

2. 创建和使用类

在C#中,创建和使用类是一种面向对象编程的基本概念。下面是创建和使用类的一些基本步骤:

2.1 创建类

使用 class 关键字来定义一个类,并给它一个名称。例如,创建一个名为 Person 的类:

class Person
{
    // 类的成员和方法将在这里定义
}

2.2 定义类的成员

在类的定义中,可以声明各种成员,如字段、属性和方法。例如,在 Person 类中添加一些字段和属性:

class Person
{
    // 字段
    public string name;
    public int age;
    
    // 属性
    public string Name
    {
        get { return name; }
        set { name = value; }
    }
    
    public int Age
    {
        get { return age; }
        set { age = value; }
    }
}

2.3 创建类的实例

使用 new 关键字创建类的实例。例如,创建一个名为 person1Person 类的实例:

Person person1 = new Person();

2.4 访问类的成员

可以使用实例名称和成员名称来访问类的成员。例如,给 person1 实例的 nameage 字段赋值:

person1.name = "John";
person1.age = 30;

也可以使用属性来访问和设置类的成员。例如,通过属性访问和设置 person1 实例的 NameAge

person1.Name = "John";
person1.Age = 30;

注意,根据成员的访问修饰符(如 publicprivate 等),对类的成员的访问可能受到限制。

3. 类的构成

在C#中,类由以下几个构成部分组成:

  • 类的名称:类有一个名称,用于标识和引用该类。

  • 字段(Fields):字段是类中的变量,用于存储对象的状态和数据。字段可以是不同的数据类型,如整数、字符串、布尔值等。字段可以是私有的(private),只能在类内部访问,也可以是公共的(public),允许在类外部访问。

  • 属性(Properties):属性提供了一种通过方法访问和修改字段的方式。属性定义了读取和写入字段值的访问器方法,以便对字段进行封装和控制访问。

  • 构造函数(Constructor):构造函数是一种特殊的方法,用于创建类的对象并初始化其状态。构造函数具有与类名称相同的名称,不返回任何值,可以接受参数以便在创建对象时提供初始值。

  • 方法(Methods):方法是类中用于执行特定任务的代码块。方法可以接受参数和返回值,可以用于操作类的字段和属性,以及执行其他操作。

  • 事件(Events):事件提供了一种机制,允许类与外部代码进行通信。类可以定义事件,外部代码可以订阅事件并在事件发生时执行相应的操作。

  • 索引器(Indexers):索引器允许通过类的实例使用类似于数组的访问语法来访问对象的元素。索引器定义了索引访问器方法,通过索引值来读取和写入对象的元素。

  • 嵌套类型(Nested Types):类可以包含其他类、结构体、枚举或接口。这些被包含的类型称为嵌套类型,它们在外部类的作用域内,可以访问外部类的成员。

3.1 成员

类包含成员,成员可以是静态成员实例成员

静态成员:

  • 静态成员是类的成员,被类的所有实例所共享。它们属于类本身,而不是类的实例。

  • 静态成员使用 static 关键字进行声明。可以是静态字段、静态方法、静态属性、静态构造函数等。

  • 静态成员在程序运行时只会被初始化一次,并且无论创建了多少个类的实例,它们的值都是相同的。

  • 静态成员可以直接通过类名访问,无需实例化类。例如,ClassName.StaticMember

  • 静态成员常用于表示与类相关的全局数据、工具方法或共享的资源。例如,全局计数器、单例模式的实现等。

实例成员:

  • 实例成员是类的成员,属于类的实例,每个类的实例都有自己的实例成员。

  • 实例成员包括实例字段、实例方法、实例属性、实例构造函数等。

  • 实例成员必须通过类的实例来访问和使用。例如,instanceName.InstanceMember

  • 每个类的实例都有自己的实例成员,并且它们的值可以相互独立。修改一个实例的实例成员不会影响其他实例。

  • 实例成员用于表示特定实例的状态和行为,可以通过实例化类来创建和使用。

3.2 字段(Fields)

在 C# 中,字段是类或结构中用于存储数据的成员。字段表示对象的状态或属性,并且可以在类或结构的内部访问和使用。

3.2.1声明字段

在类或结构的定义中,可以使用字段来声明和定义数据。字段的声明包括字段的类型、名称和可访问性修饰符(如 publicprivate 等)。例如,声明一个私有整数字段:

private int age;

3.2.2 访问字段

字段可以在类或结构的成员中进行访问和使用。它们可以用于读取和写入对象的数据。字段的访问性取决于字段声明时的访问修饰符。例如,在类的方法中访问和修改字段的值:

public void SetAge(int newAge)
{
    age = newAge;
}

public int GetAge()
{
    return age;
}

3.2.3 字段的初始化

字段可以在声明时或构造函数中进行初始化。在声明时初始化字段可以直接为其赋值,而在构造函数中初始化字段可以根据特定需求进行更复杂的初始化操作。

3.2.4 字段的可见性

字段可以具有不同的可见性修饰符,如 publicprivateprotected 等。这决定了哪些部分可以访问字段。public 字段可以从任何位置访问,private 字段只能在类的内部访问,protected 字段可以在类内部以及派生类中访问。

3.2.5 字段与属性的区别:

字段和属性都用于存储数据,但属性提供了更高级的访问控制和封装性。通过属性,可以对字段的读取和写入进行更多的控制,并隐藏字段的实现细节。属性允许使用 get 和 set 访问器来定义读取和写入字段的逻辑。

3.3 只读字段(Read-only Fields)

在 C# 中,只读字段是指在声明时或构造函数中初始化后,其值不能再被修改的字段。只读字段用于表示一次性赋值的常量或不可变的状态。

3.3.1 声明只读字段

在字段声明时,使用 readonly 关键字来标记字段为只读。只读字段必须在声明时或构造函数中进行初始化,并且不能再次被修改。例如:

private readonly int id = 100;

3.3.2 只读字段的初始化

只读字段可以在声明时或构造函数中进行初始化。在声明时初始化只读字段时,可以直接为其赋值。在构造函数中初始化只读字段时,可以根据特定需求进行更复杂的初始化操作。例如:

private readonly string name;

public MyClass(string initialName)
{
    name = initialName;
}

3.3.3 只读字段的特性:

  • 只读字段的值在初始化后不能再被修改,因此其值在对象的整个生命周期内保持不变。

  • 只读字段可以在类或结构的任何成员方法中访问。

  • 只读字段必须在声明时或构造函数中初始化,并且不能在其他方法中进行赋值操作。

3.3.4 只读字段与常量的区别

  • 只读字段与常量类似,都表示不可修改的值。但只读字段的值在运行时确定并可以根据初始化逻辑进行计算,而常量的值在编译时确定且必须是常量表达式。

  • 只读字段允许在不同的实例中具有不同的值,而常量的值对于所有实例都是相同的。

只读字段提供了一种在类或结构中表示不可变数据或状态的方式。通过使用只读字段,可以确保字段的值在初始化后不会被修改。

3.4 属性

在 C# 中,属性(Properties)是一种特殊的成员,用于封装字段的访问和修改操作。属性提供了一种更高级的方式来控制对类的字段的读取和写入,并隐藏字段的实现细节。

声明属性: 属性通常与私有字段配对使用,用于对其进行访问和修改。属性的声明包括属性的类型、名称和可访问性修饰符,以及可选的 get 和 set 访问器。例如

class PhoneCustomer
{
    private string _firstName;
    public string FirstName
    {
        get {return _firstName;}
        set {_firstName = value;}
    }
}

get 访问器不带任何参数,且必须返回属性声明的类型。也不应为 set 访问器指定任何显示参数,但编译器假定它带一个参数,其类型与属性相同,并表示为 value。

3.4.1 具有表达式体的属性访问器(Expression-bodied members)

在 C# 6 及更高版本中,可以使用表达式体成员语法来定义属性的访问器。表达式体访问器提供了一种简洁的方式来实现只有单行逻辑的属性。以下是使用表达式体的属性访问器的示例:

private string _firstName;
public string FirstName
{
    get => _firstName;
    set => _firstName = value;
}

在上面的示例中,使用箭头(=>)来定义属性的访问器。箭头左侧是 get 或 set 关键字,右侧是要返回的表达式或要赋给属性的值。使用表达式体访问器时,可以省略大括号并使用单行表达式。

表达式体访问器提供了一种更简洁和可读的方式来定义只有简单逻辑的属性。它适用于那些只有一个表达式或操作的属性,使代码更加精炼和易于理解。

3.4.2 自动实现的属性

如果属性的 set 和 get 访问器中没有任何逻辑,就可以使用自动实现的属性。自动实现属性实际上会在编译时自动创建一个后备字段,以存储属性的值。编译器会处理属性的读取和写入操作,使用该后备字段来存储和检索值。

public int Age {get; set;}

自动实现的属性不需要声明私有字段。编译器会自动创建它。使用自动实现的属性,就不能直接访问字段,因为不知道编译器生成的名称。

如果对属性所需要做的就是读取和编写一个字段,那么使用自动实现属性时的属性语法比使用具有表达式体的属性访问器时的语法更短。

如果使用自动实现的属性,就不能在属性设置中验证属性的有效性。

自动实现的属性可以使用属性初始化器来初始化:

public int Age {get; set;} = 23;

3.4.3 属性访问器的访问修饰符

C# 允许给属性的 get 和 set 访问器设置不同的访问修饰符,所以属性可以有公有的 get 访问器和私有或受保护的 set 访问器。

注意:在 get 和 set 访问器中,必须有一个具备属性的访问级别。

public string Name
{
    get => _name;
    private set => _name = value;
}

// 通过自动实现的属性,也可以设置不同的访问级别:
public int Age {get; private set;}

3.4.4 只读属性

在属性定义中省略 set 访问器,就可以创建只读属性,

private readonly string _name;
public string Name {get => _name;}

注意:可以创建只读属性就可以创建只写属性。只要在属性定义中省略 get 访问器,就可以创建只写属性。但是,这是不好的编程方式。一般情况下,如果要这么做,最好使用一个方法替代。

3.4.5 自动实现的只读属性

在 C# 中,可以使用自动实现的只读属性来表示在声明时或构造函数中初始化后不能再修改的属性。自动实现的只读属性提供了一种简化的语法,使得定义和使用只读属性更加便捷。

声明自动实现的只读属性

自动实现的只读属性使用类似于自动实现属性的语法,但省略了 set 访问器。只读属性只能在声明时或构造函数中初始化,并且不能再被修改。例如:

public string FullName { get; } = "John Doe";

初始化自动实现的只读属性

自动实现的只读属性可以在声明时进行初始化,或者在构造函数中使用属性初始化器初始化。初始化值的方式与自动实现属性相同。例如:

public DateTime CreatedDate { get; } = DateTime.Now;

访问自动实现的只读属性

自动实现的只读属性可以像访问普通属性一样进行读取操作,但无法进行写入操作。通过点运算符(.)来访问只读属性。例如:

MyClass myObj = new MyClass();
string fullName = myObj.FullName; // 获取只读属性的值

自动实现的只读属性提供了一种简洁和方便的方式来定义和使用只读属性。通过省略 set 访问器,可以确保只读属性在初始化后不会被修改,增加了代码的可靠性和安全性。自动实现的只读属性适用于表示对象的不可变状态或只能在初始化时赋值的属性。

3.4.6 表达式体属性(Expression-bodied properties)

表达式体属性是一种在 C# 6 及更高版本中引入的语法糖,用于简化只有一行表达式的属性的定义。它提供了一种更简洁的方式来声明和实现属性。

  • 表达式体属性使用类似于方法的表达式体成员语法。在属性的声明中,可以使用箭头(=>)来定义属性的 get 访问器或同时定义 get 和 set 访问器。

  • 表达式体属性通常用于只读属性。只读表达式体属性只有 get 访问器,并且不能在属性内部修改值。

  • 表达式体属性适用于简单的表达式或操作,不适用于复杂的逻辑。如果属性需要更多的逻辑处理,例如条件语句或循环,应该使用完整的属性访问器。

  • 表达式体属性允许类型推断,不需要显式指定返回值的类型。编译器可以根据表达式的结果推断属性的类型。

public class Person
{
    public Person (string firstName, string lastName)
    {
        FirstName = firstName;
        LastName = lastName;
    }
    public string FirstName {get;}
    public string LastName {get;}
    public string FullName => $"{FirstName} {LastName}"; // 表达式体属性
}

3.5 方法

注意:正式的 C# 术语区分函数和方法。

在C#中,术语函数(Function)和方法(Method)有时被广义地用作同义词,用来描述可执行的代码块。然而,严格来说,在C#中有以下区别:

  • 函数(Function):函数是一段可重复使用的独立代码块,它接受输入参数并返回一个值。函数通常是无状态的,也就是说,它不依赖于特定的对象或类,并且可以在任何地方定义和调用,独立于类或对象的上下文。在C#中,函数通常指的是静态函数(static function),它不属于任何特定的类或对象。

  • 方法(Method):方法是类或对象的成员,它封装了与类或对象相关的行为。方法可以访问和操作对象的状态,并可以被类或对象的实例调用。方法属于特定的类或对象,并在类或对象的上下文中定义和调用。在C#中,方法是面向对象编程的核心概念,可以通过类的实例(对象)调用实例方法,也可以通过类本身调用静态方法。

需要注意的是,由于C#支持静态方法和非静态方法,以及可重写的虚拟方法(virtual method)和抽象方法(abstract method),所以在具体的语境中,函数和方法的定义和用法可能会有所不同。在通常情况下,函数是指独立的代码块,方法是指类或对象的成员,但有时候两者之间的界限可以模糊。

3.5.1 方法的声明

方法的声明在 C# 基础篇开头中有提到。

[访问修饰符] <返回值类型> <方法名> ([参数列表])
{
    // 方法体;
}

例如:

public bool IsSquare(Rectangle rect)
{
    return (rect.Heigt == rect.Width);
}

如果方法没有返回值,就把返回类型指定为 void,因为不能省略返回类型,如果方法不带参数,仍需要方法名的后面包括一对空的圆括号()。此时 return 语句就是可选的——当到达闭花括号时,方法会自动返回。

3.5.2 表达式体方法

public bool IsSquare(Rectangle rect) => rect.Height == rect.Width;

3.5.3 调用方法

using UnityEngine;

public class Math
{
    public int Value { get; set; }
    public int GetSquare() => Value * Value;
    public static int GetSquareOf(int x) => x * x;
    public static double GetPi() => 3.14;
}

public class Program
{
    public static void Main()
    {
        Console.WriteLine($"Pi is {Math.GetPi()}"); // Pi is 3.14
        int x = Math.GetSquareOf(5);
        Console.WriteLine($"Square of 5 is {x}"); // Square of 5 is 25
        var math = new Math();
        math.Value = 30;
        Console.WriteLine($"Value field of math variable contains {math.Value}"); // Value field of math variable contains 30
        Console.WriteLine($"Square of 30 is {math.GetSquare()}");                 // Square of 30 is 900
    }
}

3.5.4 方法的重载

在 C# 中,方法重载(Method Overloading)是指在同一个类中定义多个方法,它们具有相同的名称但具有不同的参数列表。通过方法重载,可以根据参数的不同来选择调用合适的方法。

方法重载具有以下特点:

  • 方法名称相同:重载方法必须具有相同的名称。

  • 参数列表不同:重载方法的参数列表必须不同,可以是参数的数量、类型或顺序的不同组合。

  • 返回类型可以相同也可以不同:重载方法可以具有相同的返回类型,也可以具有不同的返回类型。

  • 访问修饰符可以相同也可以不同:重载方法可以具有相同的访问修饰符,也可以具有不同的访问修饰符。

通过方法重载,可以根据调用时传递的参数类型和数量来选择合适的方法。编译器会根据参数列表的匹配程度来确定要调用的具体方法。

以下是一个示例,演示了方法重载的用法:

public class Calculator
{
    public int Add(int a, int b)
    {
        return a + b;
    }

    public double Add(double a, double b)
    {
        return a + b;
    }

    public int Add(int a, int b, int c)
    {
        return a + b + c;
    }
}

// 使用方法重载
Calculator calculator = new Calculator();
int result1 = calculator.Add(2, 3);               // 调用 Add(int, int) 方法
double result2 = calculator.Add(2.5, 3.7);        // 调用 Add(double, double) 方法
int result3 = calculator.Add(2, 3, 4);            // 调用 Add(int, int, int) 方法

3.5.5 命名的参数

调用方法时,变量名不需要添加到调用中。然而,如果有如下的方法签名,用于移动矩形:

public void MoveAndResize(int x, int y, int width, int height)

用下面的代码片段调用它,就不能从调用中看出使用了什么数字,这些数字用于哪里:

r.MoveAndResize(30, 40, 20, 40);

可以改变调用,明确数字的含义:

r.MoveAndResize(x: 30, y: 40, width: 20, height: 40);

任何方法都可以使用命名的参数调用。只需要编写变量名,后跟一个冒号和所传递的值。编译器会去掉变量名,创建一个方法调用,就像没有变量名一样——这在编译后的代码中没有差别。C# 7.2 允许使用不拖尾的命名参数。使用早期的 C# 版本时,需要在使用第一个命名参数之后为所有参数提供名称。

还可以使用可选参数更改变量的顺序,编译器会重新安排,获取正确的顺序。

3.5.6 可选参数

参数也可以是可选的。必须为可选参数提供默认值。可选参数还必须是方法定于的最后的参数:

static void TestMethod(int notOptionalNumber, int optionalNumber = 42)
{
    print((optionalNumber + notOptionalNumber).ToString());
}

// 这个方法可以使用一个或两个参数调用。传递一个参数,编译器就修改方法调用,给第二个参数传递42。
static void Main()
{
    TestMethod(11); // 第二个参数默认42
    TestMethod(11, 22); // 修改了第二个参数,此时第二个参数是22
}

3.5.7 个数可变的参数

使用可选参数,可以定义数量可变的参数。然而,还有另一种语法允许传递数量可变的参数——这个语法没有版本控制问题。

声明数组类型的参数,添加 params 关键字,就可以使用任意数量的 int 参数调用该方法。

static void Main()
{
    AnyNumberOfArguments(1);
    AnyNumberOfArguments(1, 3, 5, 7, 9);
}

static void AnyNumberOfArguments(params int[] data)
{
    foreach (int x in data)
    {
        print(x.ToString());
    }
}

如果应该把不同类型的参数传递给方法,可以使用 object 数组:

static void AnyNumberOfArguments(params object[] data) 
{
    // 实现
}

static void Main() 
{ 
    AnyNumberOfArguments("text, 1);
}

如果 params 关键字与方法签名定义的多个参数一起使用,则 params 只能使用一次,而且它必须是最有一个参数:

Console.WriteLine(string format, params object[] arg);

3.6 拓展方法

有许多拓展类的方式。继承就是给对象添加功能的好方法。拓展方法是给对象添加功能的另一个选项,在不能使用继承时,也可以使用这个选项(如果类是密封的)。

注意:拓展方法也可以用于拓展接口。这样,实现该接口的所有类就有了公共功能。

拓展方法是静态方法,它是类的一部分,但实际上没有放在类的源代码中。

假设希望用一个方法拓展 string 类型,该方法计算字符串中的单词数。

public static class StringExtension
{
    public static int GetWordCount(this string s) => s.Split().Length;
}

使用 this 关键字和第一个参数来拓展字符串。这个关键字定义了要拓展的类型。

即使拓展方法是静态的,也要使用标准的实例方法语法。注意:这里使用变量名而没有使用类型名来调用静态方法。

public class StringExtensionTestClass
{
    const string Fox = "the quick brown fox jumped over the lazy dogs down 9876543210 times";
    public static void Main() 
    { 
        GetWordCount();
    }
  
    public void GetWordCount()
    {
        int worldCount = Fox.GetWordCount();
        print($"{worldCount} words");
    }
}

在后台,编译器会把它改为调用静态方法:

int wordCount = StringExtension.GetWordCount(fox);

使用实例方法的语法,而不是从代码中直接调用静态方法,会得到一个好很多的语法。这个语法还有一个好处:该方法的实现可以用另一个类取代,而不需要更改代码——只需要运行新的编译器。

编译器如何找到某个类型的拓展方法?this 关键字必须匹配类型的拓展方法,而且需要打开定义拓展方法的静态类所在的名称空间。如果类型还定义了同名的实例方法,拓展方法就永远不会使用。类中已有的任何实例方法都优先。当多个同名的拓展方法拓展相同的类型,打开所有这些类型的名称空间时,编译器会产生一个错误,指出调用是模棱两可的,它不能决定在多个实现代码中选择哪个。然而,如果调用代码在一个名称空间中,这个名称空间就优先。

3.7 构造函数

声明基本构造函数的语法就是声明一个与包含的类同名的方法,但该方法没有返回类型:

public class MyClass
{
    public MyClass () {}
}

没有必要给类提供构造函数。一般情况下,如果没有提供任何构造函数,编译器会在后台生成一个默认的构造函数。

这是一个非常基本的构造函数,它只能把所有的成员字段初始化为标准的默认值。

构造函数的重载遵循与其他方法相同的规则。换言之,可以为构造函数提供任意多的重载,只要它们的签名有明显的区别即可:

public class MyClass
{
    public MyClass () {}
    public MyClass (int number) {}
}

但是,如果提供了带参数的构造函数,编译器就不会自动提供默认的构造函数。只有在没有定义任何构造函数时,编译器才会自动提供默认的构造函数。

using UnityEngine;

public class Program
{
    public static void Main()
    {
        // 因为定义了一个带单个参数的构造函数,编译器会假定这是可用的唯一构造函数,所以它不会隐式地提供其他构造函数。
        var num = new MyNumber(); // error: Constructor 'MyNumber' has 1 parameter(s) but is invoked with 0 argument(s)
    }
}

public class MyNumber
{
    int m_Number;

    public MyNumber(int number)
    {
        m_Number = number;
    }
}

可以把构造函数定义为 private 或 protected,这样不相关的类就不能访问它们。

using UnityEngine;

public class Program
{
    public static void Main()
    {
        var num = new MyNumber(); // error: Cannot access private constructor 'MyNumber(int)' here
    }
}

public class MyNumber
{
    int m_Number;
    MyNumber(int number)
    {
        m_Number = number;
    }
}

这个例子没有为该类定义任何公有的或受保护的构造函数。这就是该类不能使用 new 运算符在外部代码中实例化(但可以在该类中编写一个公有静态属性或方法,以实例化该类)。这在下面两种情况下是有用的:

  • 类仅用作某些静态成员或属性的容器,因此永远不会实例化它。在这种情况下,可以用 static 修饰符声明类。使用这个修饰符,类只能包含静态成员,不能实例化。

  • 希望类仅通过调用某个静态成员函数来实例化(这就是所谓对象实例化的类工厂方法)。单例模式的实现如下面的代码片段所示:

public class Singleton
{
    static Singleton s_Instance;
    int m_State;
    Singleton(int state)
    {
        m_State = state;
    }
    public static Singleton Instance => s_Instance ??= new Singleton(42);
}

Singleton 类包含一个私有构造函数,所以只能在类中实例化它本身。为了实例化它,静态属性 Instance 返回字段 s_Instance。如果这个字段尚未初始化(null),就调用实例构造函数,创建一个新的实例。为了检查null,使用合并运算符,如果这个操作符的左边是null,就处理操作符的右边,调用实例构造函数。

?? 和 ??= 运算符 - Null 合并操作符

3.7.1 表达式体和构造函数

如果构造函数的实现由一个表达式组成,那么构造函数可以通过一个表达式体来实现:

public class Singleton
{
    static Singleton s_Instance;
    int m_State;
    Singleton(int state) => m_State = state;
    public static Singleton Instance => s_Instance ??= new Singleton(42);
}

3.7.2 从构造函数中调用其他构造函数

有时,在一个类中有几个构造函数,以容纳某些可选参数,这些构造函数包含一些共同的代码。

class Car
{
    string m_Description;
    uint m_Wheels;
    public Car(string description, uint wheels)
    {
        m_Description = description;
        m_Wheels = wheels;
    }
    public Car(string description)
    {
        m_Description = description;
        m_Wheels = 4;
    }
}

这两个构造函数初始化相同的字段,显然,最好把所有代码放在一个地方。C# 有一个特殊的语法,称为构造函数初始化器,可以实现此目的:

class Car
{
    string m_Description;
    uint m_Wheels;
    public Car(string description, uint wheels)
    {
        m_Description = description;
        m_Wheels = wheels;
    }
    public Car(string description) : this(description, 4) { }
}

3.7.3 静态构造函数

C# 的一个特征是也可以给类编写无参数的静态构造函数。这种构造函数只执行一次,而前面的构造函数是实例构造函数,只要创建类的对象,就会执行它。

class MyClass
{
    static MyClass()
    {
        // 实现
    }
}

编写静态构造函数的一个原因是,类有一些静态字段或属性,需要在第一次使用类之前,从外部源中初始化这些静态字段和属性。

在 C# 中,通常在第一次调用类的任何成员之前执行静态构造函数(静态构造函数至多执行一次)。

注意:静态构造函数没有访问修饰符,其他 C# 代码从来不显式调用它,但在加载类时,总是由 .NET 运行库调用它,所有像 public 或 private 这样的访问修饰符就没有任何意义。出于同样的原因,静态构造函数不能带任何参数,一个类也只能有一个静态构造函数。很显然,静态构造函数只能访问类的静态成员,不能访问类的实例成员。

无参数的实例构造函数与静态构造函数可以在同一个类中定义。尽管参数列表相同,但这并不矛盾,因为在加载类时执行静态构造函数,而在创建实例时执行实例构造函数,所以何时执行哪个构造函数不会有冲突。

3.8 可空类型

可空类型(Nullable Types)是 C# 中的一种特殊类型,它允许变量持有一个额外的null值,除了正常的值类型。可空类型提供了一种方便的方式来表示可能存在或缺失值的情况。

在C#中,可空类型是通过在值类型后面添加一个问号(?)来声明的。例如,int?表示一个可空的整数类型。

使用可空类型的主要优势在于:

  • 处理缺失值:在某些情况下,变量可能没有有效的值,而是具有null值。通过使用可空类型,可以明确表示这种缺失值的状态,而不是使用特殊的标记值来表示缺失。

  • 类型安全:可空类型在编译时提供了类型安全检查,确保只有可空类型的变量才能赋值为null,而非可空类型的变量不能直接赋值为null

  • 简化判断:可空类型可以使用条件语句(如if语句)或空值合并运算符(??)来方便地判断变量是否为null,并执行相应的操作。

以下是一个示例,演示了可空类型的用法:

int? nullableInt = null;    // 可空的整数类型,初始值为 null
int regularInt = 10;        // 普通的整数类型

if (nullableInt.HasValue)
{
    Console.WriteLine("nullableInt has a value: " + nullableInt.Value);
}
else
{
    Console.WriteLine("nullableInt is null");
}

int result = nullableInt ?? regularInt;   // 使用空值合并运算符,如果 nullableInt 为 null,则使用 regularInt 的值

Console.WriteLine("Result: " + result);

在上述示例中,我们声明了一个可空整数类型nullableInt,并将其初始化为null。然后,我们使用HasValue属性来检查可空类型是否具有值,使用Value属性来获取可空类型的实际值(如果有值)。我们还使用了空值合并运算符(??),如果可空类型为null,则返回后面的默认值。

?? 和 ??= 运算符 - Null 合并操作符

3.9 部分类(Partial Class)

部分类是 C# 中的一种特殊类型,它允许将一个类的定义分成多个部分,并存储在不同的物理文件中。这种分割有助于组织大型类或扩展自动生成的代码。编译器会将这些部分类组合成一个单独的类。

部分类的各个部分使用partial关键字进行声明,其声明必须在同一个命名空间和相同访问修饰符的范围内。所有部分类的成员将被视为一个整体,彼此之间可以访问和共享私有成员。

partial 关键字的用法是:把 partial 放在 class、struct 或 interface 关键字的前面。

// SampleClassAutogenerated.cs
partial class SampleClass
{
    public void MethodOne(){}
}

// SampleClass.cs
partial class SampleClass
{
    public void MethodTwo(){}
}

当编译包含这两个源文件的项目时,会创建一个 SampleClass 类,他有两个方法 MethodOne() 和 MethodTwo()。

如果声明类时使用了下面的关键字,则这些关键字就必须应用于同一个类的所有部分:

public private protected internal abstract sealed new 一般约束

在嵌套的类型中,只要 partial 关键字位于 class 关键字的前面,就可以嵌套部分类。在把部分类编译到类型中时,属性、XML 注释、接口、泛型类型的参数属性和成员会合并。如有如下两个源文件:

internal interface ISampleClass { }
internal interface IOtherSampleClass { }
internal class SampleBaseClass { }

// SampleClassAutogenerated.cs
[CustomAttribute]
partial class SampleClass : SampleBaseClass, ISampleClass
{
    public void MethodOne() { }
}

// SampleClass.cs
[AnotherAttribute]
partial class SampleClass : IOtherSampleClass
{
    public void MethodTwo() { }
}

// 编译后:
[CustomAttribute]
[AnotherAttribute]
partial class SampleClass : SampleBaseClass, ISampleClass, IOtherSampleClass
{
    public void MethodOne() { }
    public void MethodTwo() { }
}

注意:尽管 partial 关键字很容易创建跨多个文件的庞大的类,且不同的开发人员处理同一个类的不同文件,但该关键字并不用于这个目的。在这种情况下,最好把大类拆分成几个小类,一个类只用于一个目的。

部分类可以包含部分方法。如果生成的代码应该调用可能不存在的方法,这就是非常有用的。拓展部分类的程序员可以决定创建部分方法的自定义实现代码,或者什么也不做。下面的代码片段包含一个部分类,其方法 MethodOne 调用 ApartialMethod 方法。ApartialMethod 方法用 partial 关键字声明,因此不需要任何实现代码。如果没有实现代码,编译器将删除这个方法调用:

// SampleClassAutogenerated.cs
internal partial class SampleClass
{
    public static void Main() 
    { 
        APartialMethod();
    }
    partial void APartialMethod();
}

部分方法的实现可以放在部分类的任何其他地方:

// SampleClass.cs
internal interface IOtherSampleClass { }

internal partial class SampleClass : IOtherSampleClass
{
    partial void APartialMethod()
    {
        for (int i = 0; i < 10; i++)
        {
            print("代码实现");
        }
    }
}

部分方法必须是 void 类型,否则编译器在没有实现代码的情况下无法删除调用。

partial 关键字

你可能感兴趣的:(C#知识库,c#,学习,经验分享)