类和接口

这篇笔记我们介绍C#语言中和面向对象相关的语法和概念,包括类、对象、接口,以及面向对象中继承、封装、多态在C#中如何实现。本章节可以结合Java/C++对比学习,观察C#的实现和这两种主流语言的异同。

class 类定义

C#中有如下几种类成员概念需要我们掌握:

  • Field(字段):类的成员变量,用于存放各种数据类型
  • Property(属性):通过属性Get/Set访问器读写字段,此时对外部来说操作的成员就是一个属性
  • Method(方法):成员函数,包含一段可执行的逻辑

字段和方法

C#类中,字段和方法的使用和Java类似,下面例子中我们定义了一个Student类,并声明了其内部的两个字段、构造方法和一个自定义的方法。

class Student
{
    private string _name;
    private int _age;

    public Student(string name, int age)
    {
        _name = name;
        _age = age;
    }

    public void SayHello()
    {
        Console.WriteLine("Hello, I am {0}, {1} years old.", _name, _age);
    }
}

代码中,我们声明两个字段为private,因此它们只能在类内部使用;而构造函数和方法SayHello被声明为了public,因此我们可以在任何位置访问。现代C#中,对于private的字段推荐采用下划线加驼峰命名风格,例如_camelCase,不过有些时候我们也可能看到camelCase、mCamelCase等字段命名风格,这些写法大多来自早期项目或是从C++转过来的老程序员,不是目前的主流风格。

方法中使用字段时,完整写法其实是this._name,不过这里this可以在没有歧义的情况下省略,这也是使用下划线加驼峰命名风格的另一个好处,因为字段和方法参数经常重名,对字段和参数使用不同风格就可以省去写this的麻烦。

下面代码我们使用new关键字对类进行了实例化,并调用了对象上的SayHello()方法。访问对象的字段或是方法,都是用.点操作符。

Student s = new Student("Tom", 18);
s.SayHello();

类和结构体的区别是,类实例化为对象后,会在堆内存中分配空间,对象是引用类型而非值类型。

构造函数

构造函数(Constructor)是创建对象时执行的特殊方法,用于初始化对象的字段和属性。构造函数的声明比较特殊,它的名称必须与类名相同且没有返回值,前面的例子中已经多次出现过构造函数了。

默认构造函数

如果我们没有为类定义任何构造函数,编译器会自动生成一个公开的无参构造函数,但一旦我们定义了任何一个构造函数,编译器就不再自动生成,这意味着下面这种情况会编译报错。

class Student
{
    public Student(string name) { }
}

Student s = new Student(); // 编译报错:Student没有无参构造函数

构造函数重载和链式调用

构造函数和方法一样支持重载,我们可以为类提供多个参数不同的构造函数。当多个构造函数之间有重复的初始化逻辑时,可以使用: this(...)让一个构造函数先调用另一个,避免重复代码。

class Student
{
    private string _name;
    private int _age;

    public Student(string name) : this(name, 18)
    {
    }

    public Student(string name, int age)
    {
        _name = name;
        _age = age;
    }
}

上面代码中,Student(string name)通过: this(name, 18)把初始化工作委托给了Student(string name, int age),这种写法叫做构造函数的链式调用。

静态构造函数

使用static修饰的构造函数称为静态构造函数,静态构造函数不能有访问修饰符也不能有参数,它会在该类第一次被使用(即首次实例化或首次访问静态成员)之前自动执行一次,通常用于初始化静态字段。

class Config
{
    public static string Env { get; }

    static Config()
    {
        Env = Environment.GetEnvironmentVariable("APP_ENV") ?? "dev";
    }
}

private构造函数

构造函数也可以是private的,此时类外部无法使用new实例化它。前面单例模式例子中private MyObject() { }就是这个用法;此外,一个只提供静态成员的类(在静态类语法出现之前)也常用私有构造函数来阻止被调用方错误的实例化。

析构函数

和构造函数对应,C#中还有析构函数(Destructor),析构函数的语法是在类名前加~,它会在对象被垃圾回收(GC)时由运行时自动调用。

class Demo
{
    ~Demo()
    {
        // 对象被GC回收时执行
    }
}

C#的析构函数实际开发中较少使用,我们需要注意两点:一是GC回收的时机不确定,我们不能指望析构函数及时执行;二是定义了析构函数的对象会拖慢GC的回收效率。如果类持有文件句柄等非托管资源,正确的做法是实现IDisposable接口并配合using语句使用(见控制流程语句章节),而不是依赖析构函数。这里我们了解其存在即可。

static 静态字段和静态方法

和Java类似,C#也允许类拥有静态字段和静态方法,静态字段位于程序内存的静态区,可以被所有的类实例共享。静态字段一般使用类名和点操作符来访问,而非实例变量。静态字段和静态方法的用途之一是实现单例模式,下面是一段例子代码。

class MyObject
{
    private static MyObject? _instance = null;

    private MyObject() { }

    public static MyObject GetInstance()
    {
        if (_instance == null)
        {
            _instance = new MyObject();
        }
        return _instance;
    }
}

调用一个静态方法写法如下。

MyObject m = MyObject.GetInstance();

代码中,使用static关键字声明的就是静态字段和静态方法。当然,上面的单例模式实现并不完整,比如没有考虑线程安全问题等,这里只是简单演示静态字段和方法的使用,有关设计模式这里就不多介绍了。

属性和属性访问器

Java的实体类中为了访问字段经常出现一大堆Get/Set方法,十分不优雅,IDE都会自动帮我们把Get/Set方法折叠起来,甚至发展出Lombok这种邪路。而C#提出了属性的概念,同时支持属性访问器写法,相比Java能够大幅简化代码并提高可读性,属性访问器写法例子如下。

class Student
{
    private string _name;
    public string Name
    {
        get { return _name; }
        set { _name = value; }
    }
    public int Age { get; set; }
}

上面代码中,Name属性下面编写了一组get和set代码块,这就是属性访问器,代码块中我们可以像编写方法一样加入代码逻辑,这里我们就是简单的返回了属性的值,另外注意set块中,C#规定使用value关键字代表传入的参数。Age属性则使用了一种简化的属性访问器写法,它代表不做任何处理直接进行属性的返回或赋值,它更加简洁,甚至连后备字段都不用写了,实际开发中简单的赋值和取值推荐用这种写法。

这里我们的Name和Age属性都是公有访问的,因此使用了首字母大写的形式。对于属性访问器,有时我们需要类似“公有读,私有写”的需求,此时也可以单独指定get和set访问器的权限。

public class Student
{
    public string Name { get; private set; }
}

操作属性和操作字段的写法是一样的,都是使用.点操作符,例子代码如下。

Student s = new Student();
s.Age = 18;
int age = s.Age;

除此之外,C#也支持静态属性,这里就不多介绍了。

索引器

前面字符串、数组和集合章节中,我们一直在使用obj[i]这样的写法读取元素,这种能力其实并不是数组的专利,我们可以为自己的类定义索引器(Indexer),让类的实例也支持用下标访问。索引器可以看作一种「带参数的属性」,它的声明语法和属性非常相似,只是名称固定为this加一对方括号。下面例子中,我们的FruitBasket就实现了自定义索引器。

class FruitBasket
{
    private string[] _fruits = new string[10];

    public string this[int index]
    {
        get { return _fruits[index]; }
        set { _fruits[index] = value; }
    }
}

使用时和访问数组元素相同。

FruitBasket basket = new FruitBasket();
basket[0] = "apple";
Console.WriteLine(basket[0]); // apple

关于索引器有几点需要注意:

  1. 索引器没有自动实现的写法,get和set中必须自己编写存取逻辑,底层通常需要一个后备的数组或集合。
  2. 索引器的参数不一定是整数,用字符串等其它类型作下标也是合法的,Dictionary<K,V>的索引器就是典型例子。
  3. 索引器可以重载,只要参数列表不同即可,例如可以同时提供this[int index]和this[string key]两个索引器。

对象初始化器

如果一个类的属性都是可写的,我们在new对象时可以使用对象初始化器语法,在大括号中直接为属性赋值,不必先new再逐行赋值,实际开发中推荐优先使用,这种写法其实只是编译器提供的语法糖,但它能让创建对象的代码紧凑很多,下面是一个例子。

Student s = new Student { Name = "Tom", Age = 18 };

这行代码等价于先调用无参构造函数再依次执行s.Name = "Tom"; s.Age = 18;,如果构造函数本身需要参数,也可以组合使用,例如new Student("Tom") { Age = 18 };。

类似的语法还有集合初始化器new List<string> { "a", "b" },我们在集合类章节已经见过了。

C# 11还引入了required修饰符,可以实现强制要求调用方在对象初始化器中必须给某个成员赋值,否则编译报错。

class Student
{
    public required string Name { get; set; }
}

Student s = new Student(); // 编译报错:必须给required成员Name赋值
Student t = new Student { Name = "Tom" }; // 正确

匿名类型

有时候我们需要一个临时的数据载体,为它单独定义一个类很不划算,这时可以使用匿名类型。匿名类型顾名思义可以不指定类名,它直接用new { }创建对象,编译器会自动生成一个只包含这些只读属性的类。

var person = new { Name = "Tom", Age = 18 };
Console.WriteLine(person.Name); // Tom

匿名类型没有类名,所以只能用var接收,且其属性是只读的,创建后不能再赋值。但它和object不同,编译器知道该类有哪些属性,因此person.Name可以正常获得代码补全和类型检查。

匿名类型最典型的应用场景是LINQ查询的投影(select new { ... }),我们会在LINQ章节大量见到这种写法。另外提一句,匿名类型的相等性是按属性值比较的,两个属性值完全相同的匿名类型对象使用Equals()比较结果为true。

常量字段(const)和只读字段(readonly)

C#支持在类内部声明常量字段成员,对于类中一些固定不会再改变的数据字段,声明为常量字段是一个比较好的做法。常量成员需要在声明的同时初始化,其值需要在编译期确定,且不可再更改,这是因为常量会在编译期对引用常量的位置代码进行替换,常量在运行期并不存在于数据段的内存中。此外,C#中,声明为常量的成员就不能再声明为静态了,因为它本质上已经具备了静态的语义,语言规范禁止同时使用static和const两个关键字,以避免代码中的语法冗余。

class MathConstants
{
    public const double Pi = 3.14;
}

常量字段的取用类似静态成员,都是使用类名和点操作符。

double d = MathConstants.Pi;

readonly字段和常量字段本质上的区别就是readonly字段存在于运行期的数据段内存,它本质和对象的字段没有任何区别,只不过是出于程序规范性的设计,语法限制其为只读。

class Dog
{
    private readonly string _voice;

    public Dog(string voice)
    {
        _voice = voice;
    }

    public void Bark()
    {
        Console.WriteLine(_voice);
    }
}

readonly修饰的成员可以在声明语句或是构造函数中进行初始化。

partial 部分类

C#中一个比较实用的语法是partial部分类。考虑这样一种场景,我们编写一个Java Swing程序,IDE具备一个图形界面设计器(如Window Builder),它会生成一部分类的代码;而我们自己也需要编写一部分类的代码。如果没有部分类的设计,我们就不得不和IDE修改同一个代码文件,这可能造成恼人的冲突问题。而C#提供了部分类语法,可以将一个类分开到不同代码块,这样我们修改类的内容不会影响到IDE的解析,IDE维护的代码也不会污染我们的代码,Winform就是基于部分类设计的。

partial class Dog
{
    private string _voice;

    public Dog(string voice)
    {
        _voice = voice;
    }
}

partial class Dog
{
    public void Bark()
    {
        Console.WriteLine(_voice);
    }
}

上面代码中,我们定义了两个Dog类,但它们都是partial class,也就是部分类。在运行时,它们的表现和一个类没有任何区别。

嵌套类

C#允许在一个类的内部再定义一个类,这被称为嵌套类。嵌套类可以访问外部类的所有成员(包括private成员),它常用于封装一个只服务于外部类的实现细节,避免污染命名空间。

class Zoo
{
    private string _name = "市区动物园";

    class Ticket
    {
        public void Print(Zoo zoo)
        {
            // 嵌套类可以访问外部类的private成员
            Console.WriteLine(zoo._name + " 门票");
        }
    }
}

嵌套类默认是private的,即只能在外部类内使用,显式声明为public后外部也可以通过Zoo.Ticket这样的全名来使用它。

static 静态类

C#支持静态类的概念,静态类中只能包含静态成员。

static class A
{
    private static string _data = "hello";

    public static void Say()
    {
        Console.WriteLine(_data);
    }
}

静态类不能使用new关键字进行实例化,对于某种只包含静态方法和字段的工具类等,将其声明为静态类是一个好的习惯,它能够防止工具类的使用者错误的进行实例化操作。此外,静态类是密封的,不能被继承。

成员访问修饰符

上面代码中我们使用过了public和private访问修饰符,这两种访问修饰符也是最为常用的。C#语言中,一共提供了如下几种成员的访问修饰符:

访问修饰符 说明
public 公有访问,不受任何限制
private 私有访问,仅限于本类内部,子类等均不可访问
protected 保护访问,仅限于本类和子类访问
internal 内部访问,仅限于本程序集访问
protected internal 内部或保护访问,同程序集或本类和子类访问,二者是OR关系,框架开发时可能用到,日常一般较少使用
private protected 内部且保护访问,同程序集且本类和子类访问,二者是AND关系

关于默认访问修饰符则有点复杂,其规则如下:

  1. 命名空间下只允许使用public和internal,默认为internal。
  2. 类中的所有成员默认为private,接口成员默认为public。
  3. 派生类的可访问性不能高于基类;成员的可访问性不能高于包含该成员的类。

方法

C#作为一种面向对象语言,函数可以作为类的成员,在OOP中这类函数叫做「方法(Method)」。方法包含两部分:方法声明和代码块。

class Account
{
    public int Money { get; private set; }

    /// <summary>
    /// 转账方法
    /// </summary>
    /// <param name="money">转入金额</param>
    /// <returns>当前账户金额</returns>
    public int TransferMoney(int money)
    {
        this.Money += money;
        return this.Money;
    }
}

上面代码是一个典型的方法定义,声明部分包含了方法的返回值、方法名称和参数,大括号内是方法的具体执行逻辑。作为一个类的成员,我们这里使用public对方法进行修饰,因此在代码的任何位置都可以通过Account的实例对该方法进行访问。我们这里还使用了标准的///注释对方法的作用、参数、返回值进行了说明,编写具有良好注释的代码是推荐的做法。

命名参数

C#支持类似Python的命名参数语法,调用方法时可以不严格指定参数的顺序。

Student s = new Student(name: "Tom", age: 18);

上面例子中,我们Student类的构造函数声明为public Student(string name, int age),我们调用时使用了命名参数的方式,此处我们写作new Student(age: 18, name: "Tom")也是可以的。

默认参数

C#支持方法的默认参数语法,调用方法时,带有默认值的参数可以使用默认值而不必再传。

class Student
{
    private string _name;
    private int _age;

    public Student(string name, int age = 18)
    {
        _name = name;
        _age = age;
    }
}

params 可变参数

有些方法需要接受数量不定的参数,例如Console.WriteLine("i {0} j {1}", result1, result2)可以接收任意多个格式化参数。这种能力通过params关键字实现,params修饰的参数是一维数组,调用方既可以传入一个数组,也可以直接逐个传入任意多个同类型的值,编译器会自动把它们打包成数组。

class Program
{
    static int Sum(params int[] nums)
    {
        int sum = 0;
        foreach (int n in nums)
        {
            sum += n;
        }
        return sum;
    }

    static void Main()
    {
        Console.WriteLine(Sum(1, 2, 3));        // 6,逐个传入
        Console.WriteLine(Sum(new[] { 1, 2 })); // 3,传入数组
        Console.WriteLine(Sum());               // 0,不传也可以,得到空数组
    }
}

使用params有两点限制需要注意:一是params参数必须位于参数列表的最后,一个方法只能有一个params参数;二是params只能修饰一维数组类型。

方法重载

C#支持在同一个类中定义多个同名的方法,只要它们的参数列表不同(参数个数、参数类型或参数顺序不同),这被称为方法重载(Overload)。调用时编译器会根据传入的实际参数自动选择最匹配的那个版本。

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

上面代码中,三个Add方法构成一组重载,Calculator.Add(1, 2)会调用第一个版本,Calculator.Add(1.0, 2.0)会调用第二个版本,Calculator.Add(1, 2, 3)会调用第三个版本。

关于方法重载,有几点需要注意:

  1. 重载只看参数列表,不看返回值。仅仅返回值类型不同的两个同名方法无法构成重载,编译器会报错,因为调用处往往无法从返回值推断该调用哪一个。
  2. 参数上的ref、out、in修饰符也会参与区分重载,例如void Foo(int i)和void Foo(out int i)是两个不同的方法。
  3. 重载解析发生在编译期,编译器会选择当前上下文中「最合适」的版本,它与后文介绍的方法重写(override,运行期多态)是完全不同的两个概念,不要混淆。
  4. 重载不要滥用。如果一组重载方法的行为差异很大,会让调用者难以预料;默认参数和重载混用时还可能产生二义性导致编译报错,此时应显式使用命名参数来消除歧义。

标准库中就有大量重载的例子,例如Console.WriteLine()提供了接收string、int、object乃至格式化参数等不同版本的方法,我们使用起来才会感觉那么顺手。

表达式主体成员

对于只包含一条表达式的方法,C# 6.0引入了表达式主体成员(expression-bodied member)语法,可以省略大括号和return关键字,直接使用=>将方法签名和表达式连接起来,让代码更加紧凑。

class Calculator
{
    // 普通写法
    public int Add1(int a, int b)
    {
        return a + b;
    }
    // 表达式主体写法,与上面完全等价
    public int Add2(int a, int b) => a + b;
}

表达式主体不仅能用于方法,C# 7.0起,属性、索引器、构造函数、析构函数以及属性的get/set访问器都支持这种写法,其中最常用的是用表达式主体定义只读属性(相当于只有get访问器的属性)。

class Student
{
    public string FirstName { get; set; } = "";
    public string LastName { get; set; } = "";

    // 只读属性的表达式主体写法
    public string FullName => $"{FirstName} {LastName}";

    // 上面的写法等价于
    // public string FullName
    // {
    //     get { return $"{FirstName} {LastName}"; }
    // }
}

我们在枚举、结构体等前置章节的例子中其实已经见过这种写法了,例如public double Area => Math.PI * Radius * Radius;。

这里要注意,表达式主体成员中的=>和Lambda表达式中的=>是同一个符号,但含义有区别:Lambda表达式是创建一个匿名函数(委托),而表达式主体只是简写成员的定义。此外,表达式主体只能包含一条表达式,方法体中有多条语句时仍然需要使用大括号的普通写法。

局部函数

局部函数其实就是在方法内部再定义一个函数,局部函数和局部变量一样,只能在方法作用域内使用。适当的使用局部函数抽离可复用的逻辑,对代码结构的优化十分有帮助。

class Program
{
    static void Main()
    {
        void Init()
        {
            Console.WriteLine("初始化中...");
        }
        Init();
        Console.WriteLine("成功!");
    }
}

上面代码中,我们在Main()方法内部定义了局部函数Init()并调用。

扩展方法

C#中的扩展方法是一个特别好用的特性,它能够为一个已经存在的类添加方法。这在底层框架的开发中特别有用,例如我们的一个底层工具类被拆分成了多个模块,安装子模块可以为工具类添加不同的扩展功能,这就可以用扩展方法来实现。微软在.NET Core中提供的许多包就大量使用了扩展方法。

public class Student
{
    public string Name { get; set; }
    public int Age { get; set; }
}

public static class StudentExtension
{
    public static void SayHello(this Student student)
    {
        Console.WriteLine($"Hi, my name is {student.Name}. I am {student.Age} years old.");
    }
}

上面代码中,我们首先定义了类Student,而随后定义了一个静态类StudentExtension,其中包含了静态方法SayHello(),它就是一个扩展方法。只要Student类的使用者引入了StudentExtension的命名空间,就可以在Student实例上调用SayHello()方法。扩展方法中,其第一个参数需要使用this关键字指定,同时其类型需要指定为被扩展的类,代表执行扩展方法的对象实例。

Student student = new Student();
student.Name = "Tom";
student.Age = 18;
student.SayHello();

调用扩展方法时没什么特别的,只要引入了对应的命名空间,我们直接像正常使用类的写法即可。

继承

继承是OOP面向对象编程中最重要的概念,继承表达的是软件工程中的泛化关系。C#中,我们使用继承能够定义类的派生类。

class Animal { }

class Dog : Animal { }

C#中,继承使用:冒号表达。上面例子代码中,Dog类继承Animal类。关于子类能够访问父类的哪些方法,可以参考上面成员访问修饰符章节。

方法重写

方法重写用于实现面向对象的多态。C#语言中,子类可以重写(override)父类的虚方法(virtual)和抽象方法(abstract)。虚方法和抽象方法的区别:

  • 虚方法:使用virtual修饰,用于修饰父类方法,如果子类未重写该方法,则调用父类的虚方法实现
  • 抽象方法:使用abstract修饰,仅用于抽象类,抽象方法只有声明没有实现

重写基类的方法需要使用override关键字修饰。

虚方法

下面例子代码中,类A中包含了虚方法Foo,类B重写了这个虚方法。

class A
{
    public virtual void Foo()
    {
        Console.WriteLine("Function a.Foo is called.");
    }
}

class B : A
{
    public override void Foo()
    {
        Console.WriteLine("Function b.Foo is called.");
    }
}

分别实例化A和B,其中B用子类实现了父类类型,调用Foo方法时我们会获得不同的结果。

A obj1 = new A();
obj1.Foo(); // 输出:Function a.Foo is called.
A obj2 = new B();
obj2.Foo(); // 输出:Function b.Foo is called.

抽象类和抽象方法

抽象方法必须定义在抽象类中,抽象类可以包含普通方法,也可以包含抽象方法,抽象方法只有声明没有实现;抽象类不能实例化,只能实例化其派生类,且派生类必须重写父类的抽象方法。

abstract class A
{
    public abstract void Foo();
}

class B : A
{
    public override void Foo()
    {
        Console.WriteLine("Function Foo is called.");
    }
}
A o = new B();
o.Foo(); // 输出:Function Foo is called.

密封类和密封方法

密封类使用sealed修饰,密封类不能被继承。

sealed class A {}

sealed修饰方法时则需要和override结合使用,表示该方法是从基类重写的,但不允许派生类进一步重写。

class B : A
{
    public override sealed void Foo()
    {
        // ...
    }
}

此时继承B的子类不能继续重写Foo方法。

成员覆盖

C#支持成员覆盖语法。覆盖和重写不同,两者的区别是:用子类实例化父类类型,得到一个父类对象,在其上调用父类定义的方法,重写会改变该方法执行子类中的方法定义,覆盖不会。

此外,覆盖不仅可以用于方法,也可以用于字段和属性,子类中覆盖父类的成员需要使用new关键字修饰。

下面例子实现了子类覆盖基类方法。

class A
{
    public void Foo()
    {
        Console.WriteLine("Function A.Foo is called.");
    }
}

class B : A
{
    public new void Foo()
    {
        Console.WriteLine("Function B.Foo is called.");
    }
}

父类和子类对象分别用不同方式进行实例化,输出如下。

A obj1 = new A();
obj1.Foo(); // 输出:Function A.Foo is called.
A obj2 = new B();
obj2.Foo(); // 输出:Function A.Foo is called.
B obj3 = new B();
obj3.Foo(); // 输出:Function B.Foo is called.

使用覆盖时还有个问题需要注意,如果子类覆盖了父类成员,this中能够访问到的成员是覆盖后的,此时可以使用base关键字访问父类成员。

class A
{
    protected string data = "aaa";
}

class B : A
{
    protected new string data = "bbb";

    public void Foo()
    {
        Console.WriteLine("base {0} this {1}", base.data, this.data);
    }
}

调用Foo方法输出base aaa this bbb。

继承情况下的构造函数

C#语言中,继承情况下,子类会默认先调用父类的无参构造函数,也可以指明调用父类的带参数构造函数。

class A
{
    public A()
    {
        Console.WriteLine("A构造函数执行");
    }
}

class B : A
{
    public B() : base()
    {
        Console.WriteLine("B构造函数执行");
    }
}

上面代码中public B() : base()声明表示调用B()前先执行父类无参构造函数,也就是A()。其实这里: base()可以省略不写,因为这是默认行为,这里显式写出只是为了演示相关的用法。

对于父类的带参数构造函数,我们就不能省略base()了,必须显式写出,其参数可以直接使用构造函数的参数,也可以使用字面值。下面是一个例子:

public B(int age, string name) : base(age, name) { }

调用base()时和普通函数一样,我们也可以使用命名参数:

public B(int age, string name) : base(age: age, name: name) { }

object基类和对象的相等性

前面介绍过,C#中所有类型都派生自object(即System.Object),这意味着任何对象都天然拥有Object上定义的几个方法,常用的如下。

方法 说明
ToString() 返回对象的字符串表示,默认输出类名,通常我们会重写它
GetType() 返回对象的运行时类型Type对象,反射章节会用到
Equals() 判断两个对象是否「相等」
GetHashCode() 返回对象的哈希码,供Dictionary、HashSet等哈希结构使用

ToString()的重写我们在结构体章节已经演示过,GetType()在反射章节会用到,这里重点介绍Equals()和GetHashCode(),它们决定了对象的相等性语义。

Equals()方法和==的区别

对于引用类型,Object.Equals()的默认实现和==运算符一样,比较的都是引用地址,即两个变量是否指向同一个对象;对于值类型,Equals()默认比较的是各字段的值。

class Student
{
    public string Name { get; set; }
}

Student a = new Student { Name = "Tom" };
Student b = new Student { Name = "Tom" };
Console.WriteLine(a == b);        // False,指向两个不同对象
Console.WriteLine(a.Equals(b));   // False,默认也是比较引用

上面代码中两个Student内容完全一样,但比较结果却是false,因为它们是内存中的两个独立对象,地址不同。注意string是个特例,它重写了Equals()并重载了==,因此字符串比较的是内容(见字符串章节)。

重写 Equals 和 GetHashCode

如果我们希望自己的类按照内容比较相等,就需要重写Equals()。

class Student
{
    public string Name { get; set; } = "";
    public int Age { get; set; }

    public override bool Equals(object? obj)
    {
        if (obj is Student other)
        {
            return this.Name == other.Name && this.Age == other.Age;
        }
        return false;
    }

    public override int GetHashCode()
    {
        return HashCode.Combine(Name, Age);
    }
}

不过这里有一条必须遵守的约定,重写Equals()时必须同时重写GetHashCode(),并保证相等的对象拥有相同的哈希码,否则这个类的对象放进Dictionary、HashSet这类基于哈希的集合时会出现诡异的行为,两个Equals相等的对象可能被当作不同的键,导致重复插入或查找失败,这也是为什么编译器会对只重写Equals()的类给出警告的原因。

此外,实际开发中还常让类实现IEquatable<T>接口,提供强类型的Equals(T)方法,它可以避免object参数带来的装箱和类型判断开销,标准库集合也会优先使用它,这里就不展开演示了。

接口

C#中接口使用和Java差不多。C#中,接口建议使用大写字母I开头,实现接口和继承一样使用冒号,接口中声明的方法不需要任何访问修饰符。

interface IAnimal
{
    void Bark();
}

class Dog : IAnimal
{
    private string _voice = "woof";

    public void Bark()
    {
        Console.WriteLine(_voice);
    }
}

下面我们使用实现类实例化了接口类型。

IAnimal animal = new Dog();
animal.Bark();

接口中可以声明的成员

接口中不止能声明方法,属性、事件、索引器都可以出现在接口中,实现接口的类只需提供对应的实现即可。

interface IAnimal
{
    // 属性:要求实现类提供可读写的Name属性
    string Name { get; set; }
    // 索引器:要求实现类支持用下标访问
    string this[int index] { get; }
    // 方法
    void Bark();
}

需要注意的是,接口中声明的属性只是一个「契约」,它不要求实现类用什么方式实现,自动属性、带后备字段的完整属性都可以,接口本身不包含任何字段。

显式接口实现

实现接口时除了上面那种普通的写法,C#还支持显式接口实现,它在成员名前加上接口名限定,并且不写任何访问修饰符,这种写法被称为显式接口实现。

interface IAnimal
{
    void Bark();
}

class Dog : IAnimal
{
    // 显式接口实现
    void IAnimal.Bark()
    {
        Console.WriteLine("woof");
    }
}

显式实现的成员不能通过类的实例直接访问,只能通过接口类型调用。

Dog dog = new Dog();
// dog.Bark();      // 编译报错
IAnimal animal = dog;
animal.Bark();      // 只能通过接口调用

显式接口实现主要有两个用途:

  1. 解决多接口的成员签名冲突:一个类实现两个接口,而两个接口恰好声明了同名方法时,显式实现可以为两个接口分别提供不同的实现。
  2. 隐藏实现细节:不希望类的使用者直接调用的接口成员,可以用显式实现将其「藏」起来,类的公开API上就不会出现它。

接口默认方法实现(C# 8.0)

C# 8.0起,接口中的方法可以带有默认实现,实现类不提供自己的版本时就会使用接口中的默认版本。

interface IAnimal
{
    void Bark() => Console.WriteLine("...");
}

这个特性的主要用途之一是框架演进,框架作者想给已经发布的接口添加新成员时,提供默认实现就不会破坏已有的实现类。

类实现多个接口

C#不允许多继承,但允许实现多个接口。

class Dog : IAnimal, IWalk { }

代码中,多个接口使用逗号分隔。

接口的继承

和Java一样,C#的接口可以具有继承关系。

interface A { }

interface B : A { }

上面代码中,接口B继承接口A。

record 记录类型(C# 9.0)

实际开发中我们经常需要定义一些「只为装数据」的类,比如DTO、配置项、查询结果等。如果用class来编写,为了获得合理的字符串输出和按值比较的能力,我们不得不手写构造函数、ToString()、Equals()、GetHashCode()等一大堆样板代码。C# 9.0引入的记录类型(record)就是为这种场景设计的。

最简单的记录类型只需要一行代码。

record Point(int X, int Y);

这种写法称为位置记录(positional record),编译器会自动为我们生成一个接收X和Y的构造函数、两个同名属性、ToString()、以及完整的Equals()和GetHashCode()实现。

Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
Console.WriteLine(p1);            // Point { X = 1, Y = 2 }
Console.WriteLine(p1 == p2);      // True,按值比较相等!
Console.WriteLine(p1.Equals(p2)); // True

注意上面代码中==的结果是True,这正是记录类型和普通类最大的区别:record默认按值比较相等,编译器自动生成了我们前面在相等性章节手动编写的那一整套逻辑,甚至连==运算符都重载好了。

当然,我们也可以在记录类型中添加自己的成员,或者改用大括号的完整写法。

record Point(int X, int Y)
{
    public double DistanceToOrigin()
    {
        return Math.Sqrt(X * X + Y * Y);
    }
}

with表达式和非破坏性修改

位置记录的属性默认是init访问器(见下文),即只能在初始化时赋值,之后不能再修改。那如果想基于一个已有对象得到一个「只有个别属性不同」的新对象怎么办呢?可以使用with表达式。

Point p1 = new Point(1, 2);
Point p2 = p1 with { Y = 5 };
Console.WriteLine(p1); // Point { X = 1, Y = 2 },原对象不受影响
Console.WriteLine(p2); // Point { X = 1, Y = 5 }

with表达式会复制原对象并应用大括号中的修改,这种「复制后修改」的写法称为非破坏性修改,非常适合不可变数据的使用场景。

init 访问器

init是C# 9.0引入的一种新的属性访问器,它和set类似,但只允许在对象初始化阶段(构造函数或对象初始化器中)赋值,之后便不能再修改。它并不是record专属的,普通类中也可以使用。

class Student
{
    public string Name { get; init; } = "";
}

Student s = new Student { Name = "Tom" }; // 初始化时可以赋值
// s.Name = "Jerry";                      // 编译报错:init属性只能在初始化时赋值

init访问器让创建后不可变的对象变得容易编写,它在随意修改和只能在构造函数中赋值之间提供了一种折衷,兼顾了对象初始化器写法的便利性和不可变性。

作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。