第3章 在C#中创建类型

第3章 在C#中创建类型

3.1 类

复杂的类可能包含如下内容:

  • class ​关键字之前:类特性(Attribute​)和类修饰符。非嵌套的类修饰符有:public​、internal​、abstract​、sealed​、static​、unsafe ​和 partial​。
  • 紧接 YourClassName:泛型参数、唯一基类与多个接口。
  • 在花括号内:类成员(方法、属性、索引器、事件、字段、构造器、重载运算符、嵌套类型和终结器)

3.1.1 字段

字段可用以下修饰符进行修饰:

  • 静态修饰符:static
  • 访问权限修饰符:publicinternalprivateprotected
  • 继承修饰符:new
  • 不安全代码修饰符:unsafe
  • 只读修饰符:readonly
  • 线程访问修饰符:volatile

3.1.2 方法

方法可以用以下修饰符修饰:

  • 静态修饰符:static
  • 访问权限修饰符:publicinternalprivateprotected
  • 继承修饰符:newvirtualabstractoverridesealed
  • 部分方法修饰符:partial
  • 非托管代码修饰符:unsafeextern
  • 异步代码修饰符:async

3.1.2.1 表达式体方法(C#6)

以下仅由一个表达式构成的方法可以用表达式体方法简洁地表示,用 双箭头 来取代花括号和 return 关键字:

int Foo(int x) { return x * 2; }
// 用双箭头简化
int Foo(int x) => x * 2;
// 可以用void作为返回类型

表达式体函数 可以用 void​ ​作为返回类型:

void Foo(int x) => Console.WriteLine(x);

3.1.2.2 重载方法

方法中,除** 返回值类型 params 修饰符 **外,都可以作为方法的签名:

void Foo(int x) {...}
float Foo(int x) {...}         // 编译错误

void Goo(int[] x) {...}
void Goo(params int[] x) {...} // 编译错误

3.1.2.3 按值传递和按引用传递

参数按值传递还是按引用传递 是方法签名的一部分。例如,Foo(int)​ 和 Foo(ref int) Foo(out int)可以 同时出现在一个类中。但 Foo(ref int)​ 和 Foo(out int)不能 同时出现在一个类中:

void Foo (int x);
void Foo (ref int x);      // OK so far
void Foo (out int x);      // Compile-time error

3.1.2.4 局部方法(C#7)

即在一个方法中定义另一个方法:

void WriteCubes()
{
    Console.WriteLine (Cube (3));
    Console.WriteLine (Cube (4));
    Console.WriteLine (Cube (5));

    int Cube (int value) => value * value * value;
}

局部方法特点如下:

  1. 仅在 包含它的方法 范围内可见;
  2. 可以访问 父方法 中的局部变量和参数;
  3. 可以出现在** 属性访问器 构造器(构造函数) ** 中;
  4. 局部方法 可以 内嵌局部方法;
  5. 可以是迭代的(yield return)、异步的(async);

C#7 中局部方法不能用 static ​修饰。如果父方法是静态的,那么局部方法也是隐式静态的。

3.1.3 实例构造器

实例构造器支持以下修饰符:

  • 访问权限修饰符:publicinternalprivateprotected
  • 非托管代码修饰符:unsafeextern

从 C#7 开始,仅包含一个语句的构造器也可以使用表达式成员的写法:

public Panda(string n) => name = n;

3.1.3.1 重载构造器

为了避免重复代码,构造器可以用 this 关键字 调用另一个构造器:

public class Wine
{
	public decimal Price;
	public int Year;
	public Wine (decimal price) { Price = price; }
	public Wine (decimal price, int year) : this (price) { Year = year; }
}

构造器 A 调用构造器 B,则** 构造器 B **​ (被调用者) 先执行。

还可以向另一个构造器传递表达式:

public Wine(decimal price, DateTime year) : this(price, year.Year) { }

表达式内不能使用 this 引用,例如,不能调用实例方法(这是强制性的。由于这个对象当前还没有通过构造器初始化完毕,因此调用任何方法都有可能失败)。但是表达式可以调用静态方法。

3.1.3.3 构造器和字段的初始化顺序

字段的初始化按照 声明 的先后顺序,在构造器之 执行。

3.1.3.5 解构器(C#7)

解构器(解构方法)就像构造器的反过程构造器使用若干值作为参数,并且将它们赋值给字段;而解构器则相反将字段反向赋值给若干变量。

解构方法的名字必须为 Deconstruct ​,并且拥有一个或多个 out 参数,例如:

class Rectangle {
    public readonly float Width, Height;

    public Rectangle (float width, float height) {
        Width = width;
        Height = height;
    }

    public void Deconstruct (out float width, out float height) {
        width = Width;
        height = Height;
    }
}

解构器调用方法有三种:

  1. 赋值时显式定义变量

    var rect = new Rectangle (3, 4);
    (float width, float height) = rect;       // 解构
    Console.WriteLine (width + " " + height); // 3 4
    
  2. 赋值时隐式类型推断

    解构调用允许隐式类型推断,因此可以简写为:

    (var width, var height) = rect;
    // or
    var (width, height) = rect;
    
  3. 提取定义变量,直接赋值

    float width, height;
    (width, height) = rect;
    

上述操作也称为解构赋值

Notice

解构器返回的不是元组!其本质是一种语法。

Tips

Deconstruct​ 方法可以是 扩展方法

3.1.4 对象初始化器

如下两段代码等价:

// 对象初始化器
Bunny b1 = new Bunny { Name="Bo", LikesCarrots=true, LikesHumans=false };

// 通过临时变量赋值
Bunny temp1 = new Bunny();
temp1.Name = "Bo";
temp1.LikesCarrots = true;
temp1.LikesHumans = false;
Bunny b1 = temp1;

此处使用了临时变量,是为了确保在初始化过程中如果 抛出异常 ,不会得到一个 部分初始化的 对象。

相比可选参数,对象初始化器有更好的 二进制兼容

3.1.6 属性

属性支持以下修饰符:

  • 静态修饰符:static
  • 访问权限修饰符:publicinternalprivateprotected
  • 继承修饰符:newvirtualabstractoverridesealed
  • 非托管代码修饰符:unsafeextern

3.1.6.2 表达式属性

C#6 开始只读属性可简写为表达式体属性:

public decimal Worth => currentPrice * sharesOwned;

C#7 开始 get、set 都可以改为表达式体:

public decimal Worth
{
    get => currentPrice * sharesOwned;
    set => sharesOwned = value / currentPrice;
}

3.1.6.6 CLR 属性的实现

C#属性访问器在内部会编译为名为 get_XXX ​和 set_XXX ​的方法:

public decimal get_CurrentPrice {...}
public void set_CurrentPrice(decimal value) {...}

简单的非虚属性访问器会被 JIT(即时)编译器 内联 编译,消除了属性和 字段 访问间的性能差距。内联是一种优化方法,它用方法的 函数体 替代方法调用。

对于 WinRT 属性,编译器会将 put_XXX ​ ​作为属性命名约定而非 set_XXX ​​。

3.1.7 索引器

索引器和属性具有相同的修饰符,并且可以在 方括号前 插入 ?​ 以使用 null 条件运算:

  • 静态修饰符:static
  • 访问权限修饰符:publicinternalprivateprotected
  • 继承修饰符:newvirtualabstractoverridesealed
  • 非托管代码修饰符:unsafeextern
string s = null
Console.WriteLine(s?[0]);    // 输出“null”,而非报错

3.1.7.1 索引器的实现

编写索引器首先要定义一个名为 this 的属性,并将参数定义放在 一对方括号 中。例如:

class Sentence
{
	string[] words = "The quick brown fox".Split();

	public string this [int wordNum]      // indexer
	{ 
		get { return words [wordNum];  }
		set { words [wordNum] = value; }
	}
}

一个类型可以定义多个参数类型不同的索引器,一个索引器也可以包含多个参数:

public string this [int arg1, string arg2]
{
    get {...} set {...}
}

Warn

  • AVOID​​:避免使用有 个以上参数的索引属性。

    如果索引器的参数超过 个,它应该被设计为方法。可以考虑以 Get 或 Set 作为方法名。

如果 省略 set 访问器 ,则索引器就是只读的,并且可以使用 C#6 的表达式语法来简化定义。

public string this [this wordNum] => words[wordNum];

3.1.7.2 CLR 索引器的实现

索引器在内部会编译为名为 get_Item ​和 set_Item ​的方法,如下所示:

public string get_Item (int wordNum) {...}
public void set_Item (int wordNum, string value) {...}

Warn

该名称可以通过 IdexerNameAttribute​ 进行修改。

  • DO​:要将 Item ​ 名称用于索引属性。

    除非有明显更好的名字(例如 System.String​ 的 Chars​ 属性)。

    在 C# 中,索引器默认的名字是 Item​。IndexerNameAttribute​ 可以用来对这个名字进行定制。

3.1.8 常量

非局部常量可以使用以下的修饰符:

  • 访问权限修饰符:publicinternalprivateprotected
  • 继承修饰符:new

常量 可以 在方法内声明:

static void Main(){
    const double twoPI  = 2 * System.Math.PI;
}
常量和 static readonly ​字段的区别

常量 static readonly
可以使用的类型 内置的数据类型 不限
初始化 需在 声明时 初始化 声明、构造器 中均可初始化
赋值 编译 时赋值 运行 时赋值

对于常量, 编译 时会发生赋值:

// 代码
public static double Circumference(double radius)
{
    return 2 * System.Math.PI * radius;
}
// 编译为
public static double Circumference(double radius)
{
    return 6.2831853071795862 * radius;
}

C7.0 核心技术指南 第7版.pdf - p131 - C7.0 核心技术指南 第 7 版-P131-20240123105731

3.1.9 静态构造器

运行时将在类型使用之前调用静态构造器,以下两种行为可以触发静态构造器执行:

  • 实例化类型
  • 访问类型的静态成员

静态构造器只支持两个修饰符:

  • 非托管代码修饰符:unsafeextern
静态构造器和字段初始化顺序

静态字段初始化器会在调用静态构造器 运行。如果类型没有静态构造器,字段会在 类型被使用 之前或者在 运行时中更早 的时间进行初始化。

静态字段初始化器按照字段声明的 先后顺序 运行。

如下代码 X 初始化为 0 ,Y 初始化为 3

class Foo
{
    public static int X = Y;
    public static int Y = 3;
}

如下代码会先打印 0 ,后打印 3

class Foo
{
	public static Foo Instance = new Foo();
	public static int X = 3;

	Foo() { Console.WriteLine (X); }
}

3.1.11 终结器

终结器允许使用以下修饰符:

  • 非托管代码修饰符:unsafe

终结器(Finalizer)是只能在 中使用的方法。终结器的语法是类型的名称加上 ~ ​前缀:

class Class1
{
    ~Class1()
    {
        ...
    }
}

事实上,这是 C#语言重写 Object​ ​类的 Finalize ​ ​方法的语法。编译器会将其扩展为如下的声明:

protected override void Finalize()
{
    ...
    base.Finalize();
}

从 C#7 开始,仅仅包含一个语句的终结器可以写为表达式体语法:

~Class1() => Console.WriteLine("Finalizing");

3.1.12 分部类型和方法

编译器 并不 保证分部类型(Partial type)声明中各个组成部分之间的字段初始化顺序。

分部方法

在 C# 中,分部方法(Partial Methods)是一种特殊的方法,它们允许在 分部类(Partial Class)分部接口(Partial Interface) 中定义方法的签名,并在 接口 的另一个部分中(如果需要的话)实现这些方法。分部方法主要用于 自动生成的代码 场景,为开发者提供了一种在 不修改自动生成的源代码 的情况下扩展或自定义行为的方式。

分部方法的特点
  1. 无返回值:分部方法必须是 void ​ 返回类型。
  2. 可选实现:分部方法的实现是 可选 的。如果没有提供实现,调用该方法的代码在编译时会被 自动移除 ,因此不会有性能影响。
  3. 仅在​** 分部类 接口 中有效**:分部方法只能在 分部类接口 中定义和实现。
  4. 无访问修饰符:分部方法不能有访问修饰符(如 public​、private​ 等),因为它们默认是 private ​。
使用场景

分部方法通常用于以下场景:

  • 代码生成:在使用自动生成代码的工具或框架时(如 Entity Framework、Windows Forms 设计器等),自动生成的类可以包含分部方法,让开发者在另一个文件中添加特定的逻辑,而不需要修改自动生成的代码。
  • 提供钩子:分部方法可以作为钩子,允许开发者在自动生成的过程中插入自定义代码。
示例

假设你有一个自动生成的分部类,它定义了一个分部方法:

public partial class MyClass
{
    partial void OnSomethingHappened();
}

在类的另一个部分,你可以选择实现这个方法:

public partial class MyClass
{
    partial void OnSomethingHappened()
    {
        Console.WriteLine("Something happened!");
    }
}

如果你不提供 OnSomethingHappened​ 方法的实现,那么对这个方法的所有调用都会在编译时被移除,且不会有任何运行时成本。

总结

分部方法提供了一种在自动生成的代码中添加自定义逻辑的灵活方式,特别适用于需要与自动生成的源代码交互的场景。它们的设计确保了如果没有提供实现,不会对性能产生影响。

3.1.13 nameof​ 运算符(C#6)

类型实例(非静态)仍可通过类型名使用 nameof​ 运算符:

StringBuilder sb = new StringBuilder();
string name = nameof(sb.Length);
string name = nameof(StringBuilder.Length);    //二者等价

推荐使用** 类型名 而非 实例字段 **,可以保证代码的一致性。

3.2 继承

3.2.2 类型转换和引用转换

3.2.2.2 向下类型转换

如果向下转换失败,会 抛出 ​``​ ​:

int num = 0;
object obj = num;
string value = (string)obj;

3.2.2.3 as​ 运算符

as​ 运算符在转换出错时返回 null (而不是 抛出异常 )。

as​ 运算符只能用来进行 引用 转换:

long x = 3 as long;    // 数值转换,编译错误
object y = 4;
var value = y as int;  // 拆箱转换,编译错误

什么时候使用 as、什么时候使用类型转换呢?

C7.0 核心技术指南 第7版.pdf - p138 - C7.0 核心技术指南 第 7 版-P138-20240123125706

3.2.2.5 is​ 运算符和模式变量(C#7)

is​ 运算符使用的同时可以引入一个变量。引入的变量可以“立即”使用,且作用域 覆盖表达式之外

if (a is Stock s2 && s2.SharesOwned > 100000)
    Console.WriteLine ("Wealthy");
else
    s2 = new Stock();   // s is in scope

Console.WriteLine (s2.SharesOwned);  // Still in scope

3.2.5 隐藏继承成员

基类和子类可能定义相同的成员,此时编译器会产生一个警告,并采用如下策略避免二义性:

  • A 的引用(在编译时)绑定到 A.Counter
  • B 的引用(在编译时)绑定到 B.Counter
public class A      { public int Counter = 1; }
public class B : A  { public int Counter = 2; }

可以使用 new ​ 修饰符明确将你的意图告知编译器和其他开发者:重复的成员是有意义的。

不过 new ​ 修饰符只是告知编译器不再警告,其行为与不加 new ​ 修饰符的代码一致。

public class X      { public     int Counter = 1; }
public class Y : X  { public new int Counter = 2; }

Notice

C# 在不同上下文中的 new​ 关键字拥有完全不同的含义。特别注意 new​ 运算符和 new​ 修饰符是不同的。

3.2.6 密封函数和类

sealed ​关键字可以密封函数成员的实现,防止其他子类再次重写,但它无法阻止成员被隐藏:

// 基类
class MyClass
{
    public virtual void Print(){
        "1".Dump();
    }
}
// 派生类
class MyClass2 : MyClass
{
    public override void Print()
    {
        "2".Dump();
    }
}
MyClass2 value = new MyClass3();
// 输出 “3”
value.Print();

sealed class MyClass3 : MyClass2
{
    // 该方法将覆盖MyClass2的实现
    public override void Print()
    {
        "3".Dump();
    }
}
class MyClass4 : MyClass3
{
    // MyClass3中Print方法被密封,无法覆写,编译时报错
    public sealed override void Print()
    {
        "4".Dump();
    }
}
class MyClass5 : MyClass3
{
    // MyClass3中的Print()方法可以被隐藏
    public new void Print()
    {
        "5".Dump();
    }
}

sealed ​也可以密封整个类,该类将无法被派生。

3.2.7 base 关键字

它有两个主要用途:

  • 从子类访问重写的基类函数成员
  • 调用基类的构造器

通过 base 关键字,可以访问直属基类的成员,对被隐藏成员 有效。

3.2.8 构造器和继承

基类构造器 于子类构造器执行,保证了 基类 的初始化发生在 子类 之前。基类构造器调用方式如下:

public class Subclass : Baseclass
{
	public Subclass (int x) : base (x) { }
}

3.2.8.1 隐式调用基类的无参构造器

如果子类的构造器省略 base 关键字,那么 基类的无参数构造器 将被 隐式 调用:

public class BaseClass
{
	public int X;
	public BaseClass() { X = 1; }
}

public class Subclass : BaseClass
{
	public Subclass() { Console.WriteLine (X); }  // 1
}

如果基类没有可访问的无参数构造器,子类的构造器中就必须使用 base 关键字。

3.2.8.2 构造器和字段初始化的顺序

此处举例说明执行顺序:

public class D : B {
    int y = 1;                      // 1st
    public D(int x) : base(x + 1){  // 2nd
        ...                         // 5th
    }
}
public class B {
    int x = 1;                      // 3rd
    public B(int x) {
        ...                         // 4th
    }
}

3.2.9 重载和解析

重载被调用时,类型最明确的优先匹配。具体的调用是 编译器静态 决定的而非 运行时 决定。

static void Main(){
	Foo (new House());		// Calls Foo (House)

	Asset a = new House();
	Foo (a);				// Calls Foo (Asset)
}

static void Foo (Asset a) { "Foo Asset".Dump(); }
static void Foo (House h) { "Foo House".Dump(); }

public class Asset{
	public string Name;
}

public class Stock : Asset {   // inherits from Asset
	public long SharesOwned;
}

public class House : Asset {   // inherits from Asset
	public decimal Mortgage;
}

如果把类型改为 dynamic ​,则会在运行时决定调用哪个重载,这样就会基于对象的实际类型进行选择:

Asset a = new House(...);
Foo((dynamic)a);    // Calls Foo(House)

3.3 object 类型

3.3.1 装箱和拆箱

拆箱需要 式类型转换。运行时将检查提供的值类型和真正的对象类型是否匹配,并在检查出错的时候 抛出 InvalidCastException ​​。例如,下面的例子将 抛出异常 ,因为 long 类型和 int 类型并不匹配

object obj = 9;
long x = (long) obj;
long x = (int) obj;
object obj2 = 3.5;              // 3.5 被推断double类型
int y = (int) (double) obj2;    // 正确

C7.0 核心技术指南 第7版.pdf - p146 - C7.0 核心技术指南 第 7 版-P146-20240123171516

在 C# 中,数组是协变的,但这仅适用于引用类型。由于 int​ 是一个值类型,int[]​ 不能隐式转换为 object[]​。

3.3.2 GetType 方法和 typeof 运算符

有两种方式可以获取 System.Type​ 对象:

  • 在类型实例上调用 GetType ​ ​方法

    运行 时计算

  • 在类型名称上使用 typeof ​ ​运算符

    编译 时静态计算(如果是泛型参数,它将由 即时编译器 解析)

3.3.5 object 的成员列表

下面列出 object 的所有成员:

public class Object
{
    public Object();
  
    public extern Type GetType();
  
    public virtual Equals(object obj);
    public static bool Equals(object objA, object objB);
    public static bool ReferenceEquals(object objA, object objB);
  
    public virtual int GetHashCode();
  
    public virtual string ToString();
  
    protected virtual void Finalize();
    protected extern object MemberwiseClone();
}

3.4 结构体

结构体不支持继承,其派生自 System.ValueType ​。

除了以下内容,结构体可以包含类的所有成员:

  • 无参构造
  • 字段初始化
  • 终结
  • 成员或 protected ​ 成员

结构体的构造语义如下:

  • 结构体隐式包含一个 无法 重写的 无参 构造器,将字段按位置为 0。
  • 定义结构体的构造器时,必须显式地 为每一个字段赋值

3.5 访问修饰符

访问修饰符有 5 种:

  • public​:完全访问权限。枚举类型成员或接口成员隐含的可访问性。
  • internal​​:仅可以在程序集内访问,或供友元程序集访问。这是 非嵌套 ​** 类型 **的默认可访问性。
  • private​​:仅可以在包含类型中访问。这是类或者结构体** 成员 **的默认可访问性。
  • protected​:仅可以在包含类型或子类中访问。
  • protected internal​:protected ​和 internal ​可访问性的并集。

Eric Lippert 是这样解释的:默认情况下尽可能将一切规定为私有,然后每一个修饰符都会提高其访问级别。所以用 protected internal​ 修饰的成员在两个方面的访问级别都提高了。

Notice

另有:

  • DON'T​​:不要在抽象类中定义访问类型为 public ​、 protected-internal ​的构造函数。

    抽象类禁止进行实例化,因此不应该为抽象类定义访问类型为 public、protected-internal 的构造函数。这么做不光是错误的,还会误导用户。

    // 坏设计
    public abstract class Claim {
        public Claim() { ... }
    }
    
    // 好设计
    public abstract class Claim {
        protected Claim() { ... }
    }
    

3.5.2 友元程序集

友元程序集

3.5.3 可访问性封顶

类型的可访问性是它内部声明成员可访问性的封顶。关于可访问性封顶,最常用的示例是 internal ​类型中的 public ​成员。例如:

class MyClass {
    public void Foo(){}
}

MyClass​​ ​的(默认)可访问性是 internal ​​​,它作为 Foo​​ ​的最高访问权限,使 Foo​​ ​成为 internal ​​ ​的。而将 Foo​​ ​指定为 public​​ ​的原因一般是为了将来将 MyClass​​ ​的权限改成 public​​ ​时重构的方便。

3.5.4 访问权限修饰符的限制

重写基类函数时,重写函数的可访问性必须​** 一致 **:

class BaseClass             { protected virtual  void Foo() {} }
class Subclass1 : BaseClass { protected override void Foo() {} }  // OK
class Subclass2 : BaseClass { public    override void Foo() {} }  // Error

(若在另外一个程序集中重写 protected internal​​方法,则重写方法必须为 protected ​​。这是上述规则中的一个例外情况。)

的修饰符则不同,子类相比基类访问权限可以 ,不能

internal class A { }
public   class B : A { }          // Error
private  class B : A { }          // OK

3.6 接口

接口只能包含如下成员,这些正是类中可以定义为抽象的成员类型:

  • 方法
  • 属性
  • 事件
  • 索引器

接口成员总是隐式 public ​ 的,并且 不能 用访问修饰符声明。

C7.0 核心技术指南 第7版.pdf - p153 - C7.0 核心技术指南 第 7 版-P153-20240124171424

3.6.1 扩展接口

接口可以从其他接口 派生 ,例如:

public interface IUndoable             { void Undo(); }
public interface IRedoable : IUndoable { void Redo(); }

IRedoable​“继承”了 IUndoable ​接口的所有成员。换言之,实现 IRedoable ​的类型也必须实现 IUndoable ​的成员。

3.6.2 显式接口实现

选择显式实现接口的原因一般有三:

  1. 出现成员签名冲突
  2. 高度定制化的接口成员
  3. 对类的正常使用干扰很大的接口成员

ISerializable ​接口便符合 2、3 点,常用于显式实现。

3.6.3 虚方法实现接口成员

默认情况下,隐式实现的接口成员是密封的。为了重写,必须在基类中将其标识为 virtual ​或者 abstract​。例如:

public interface IUndoable { void Undo(); }

public class TextBox : IUndoable {
	public virtual void Undo() => Console.WriteLine ("TextBox.Undo");
}

public class RichTextBox : TextBox {
	public override void Undo() => Console.WriteLine ("RichTextBox.Undo");
}

不管从基类还是接口中调用接口成员,调用的都是子类的实现:

RichTextBox r = new RichTextBox();
r.Undo();                          // RichTextBox.Undo
((IUndoable)r).Undo();             // RichTextBox.Undo
((TextBox)r).Undo();               // RichTextBox.Undo

显式实现的接口成员不能标识为 virtual​,也不能实现通常意义的重写,但是它可以被重新实现(reimplemented)

3.6.4 在子类中重新实现接口

子类可以重新实现基类实现的任意一个接口成员。不管基类中该成员是否为 virtual​,当通过接口调用时,重新实现都能够劫持成员的实现。它对接口成员的隐式和显式实现都有效,但后者效果更好。

重新实现-显式实现

下面的例子中,TextBox​​ ** 式实现** IUndoable.Undo​​​,所以不能标识为 virtual​​​。为了重写,RichTextBox​​ ​必须** 重新实现 ** IUndoable​​ ​的 Undo​​ ​方法:

public interface IUndoable { void Undo(); }

public class TextBox : IUndoable {
	void IUndoable.Undo() => Console.WriteLine ("TextBox.Undo");
}

public class RichTextBox : TextBox, IUndoable {
	public void Undo() => Console.WriteLine ("RichTextBox.Undo");
}

从接口调用重新实现的成员时,调用的是子类的实现:

RichTextBox r = new RichTextBox();
r.Undo();                 // RichTextBox.Undo      Case 1
((IUndoable)r).Undo();    // RichTextBox.Undo      Case 2
重新实现-隐式实现

下面的例子中,TextBox​ ** 式实现** IUndoable.Undo​,且未标识为 virtual​。而 RichTextBox​ 又** 重新实现 **了 IUndoable​ 的 Undo​ 方法:

public interface IUndoable { void Undo(); }

public class TextBox : IUndoable {
	public void Undo() => Console.WriteLine ("TextBox.Undo");
}

public class RichTextBox : TextBox, IUndoable {
	public new void Undo() => Console.WriteLine ("RichTextBox.Undo");
}

调用 Undo 方法时,如下代码将输出“ RichTextBox.UndoRichTextBox.UndoTextBox.Undo ”:

RichTextBox r = new RichTextBox();
r.Undo();
((IUndoable)r).Undo();
((TextBox)r).Undo();

从结果可以看到,通过重新实现来劫持调用的方式仅在通过 接口 调用成员时有效,而从 基类 调用时无效。这个特性通常不尽人意,因为它有二义性。因此,重新实现主要适合于 式实现的接口成员。

接口重新实现的替代方法

即使是显式实现的成员,接口重新实现还是容易出问题,这是因为:

  • 子类实例无法调用基类的方法

当然,如下两个解决方案也无法解决此问题

  • 定义基类时不能预测方法是否会重新实现,或无法接受重新实现后的潜在问题

让子类通过 new​ 关键字隐藏基类方法是最不理想的方式。基类有两种方法可以避免:

  • 当隐式实现成员时, 将其标记为 virtual

  • 当显式实现成员时,如果能够预测子类可能要重写某些逻辑,则使用下面的模式:

    public class TextBox : IUndoable {
    	void IUndoable.Undo()         => Undo();    // Calls method below
    	protected virtual void Undo() => throw new NotSupportedException();
    }
    
    public class RichTextBox : TextBox {
    	protected override void Undo() => Console.WriteLine ("RichTextBox.Undo");
    }
    

如果你不希望添加任何的子类,则可以 把类标记为 sealed 以制止接口的重新实现。

3.6.5 接口和装箱

将结构体转换为接口 引发装箱。而调用结构体的隐式实现接口成员 不会 引发装箱。

interface  I { void Foo();          }
struct S : I { public void Foo() {} }

static void Main() {
	S s = new S();
	s.Foo();         // 不会装箱

	I i = s;         // 因转换为接口,发生装箱
	i.Foo();
}

3.7 枚举类型

3.7.1 枚举类型转换

可以 式将一个枚举类型转换为另一个:

HorizontalAlignment value = (HorizontalAlignment)BorderSide.Left;
public enum BorderSide { Left, Right, Top, Bottom }

public enum HorizontalAlignment
{
	Left = BorderSide.Left,
	Right = BorderSide.Right,
	Center
}

则两个枚举类型之间的转换是通过 对应的数值 进行的:

HorizontalAlignment h = (HorizontalAlignment) BorderSide.Right;
// 等价于
HorizontalAlignment h = (HorizontalAlignment) (int)BorderSide.Right;

在枚举表达式中,编译器会特殊对待数值字面量 0 。它不需要进行显式转换

BorderSide b = 0;    // No cast required with the 0 literal.
if (b == 0) ...

注意,上述代码 b​ 若不用“0”进行赋值,if​ 判断将编译错误。

3.7.2 标记枚举类型

未标记 Flags​ 特性的枚举,仍可使用 |​ 和 &​ 符合进行合并,但调用 ToString​ 方法时,会输出 一个数值 而非 一组名字

Tips

即使传入格式化字符串 "G"​,仍会输出数字。

3.7.3 枚举运算符

枚举类型可用的运算符有:

  • =​、==​、!=​、<​、>​、<=​、>=​、
  • +​、-​、^​、&​、|​、~​、
  • +=​、-=​、++​、--​、sizeof

运算逻辑如下:

  • 位运算符、算术运算符和比较运算符都返回对应整数值的运算结果;
  • 枚举类型和** 整数 类型**之间可以做加法;
  • 两个枚举类型之间 不能 做加法

3.7.4 类型安全问题

由于枚举类型可以和它对应的整数类型相互转换,因此枚举的真实值可能超出枚举类型成员的数值范围:

public enum BorderSide { Left, Right, Top, Bottom }
BorderSide b = (BorderSide) 12345;
Console.WriteLine (b);                // 12345

BorderSide b2 = BorderSide.Bottom;
b2++;									// No errors
Console.WriteLine (b2);					// 4 (illegal value)

非法的枚举值可能破坏程序,解决方法有三种:

  1. 添加 else ​、 default ​​ 语句,处理非法值。

  2. 使用静态方法 Enum.IsDefined ​ ​进行检查

    不过 Enum.IsDefined ​ 对标志枚举不起作用。此外该方法通过反射进行检查,因此还有如下准则:

    DO​:验证枚举参数。但不要用 Enum.IsDefined ​ 来检查枚举范围。

  3. 利用 Enum.ToString() ​ ​的行为,可以在枚举合法时返回 true​​

    static bool IsFlagDefined (Enum e){
    	return !decimal.TryParse (e.ToString(), out _);
    }
    

3.9 泛型

3.9.1 泛型类型

技术上,我们称 Stack<T> ​是** 开放 类型,称 Stack<int> ​是 封闭 类型。在运行时,所有的泛型实例都是 封闭 **的,占位符已经被类型填充。这意味着以下语句是非法的:

var stack = new Stack<T>();    // 非法,T是什么?

3.9.3 泛型方法

在泛型中,只有 引入类型参数(用尖括号标出) 的方法才可归为泛型方法。泛型 Stack​ 类中的 Pop​ 方法仅仅使用了类型中已有的类型参数 T,因此不属于泛型方法。

唯有** 方法 **可以引入类型参数。属性、索引器、事件、字段、构造器、运算符等都不能声明类型参数,虽然它们可以参与使用所在类型中已经声明的类型参数。例如,在泛型的栈中我们可以写一个索引器返回一个泛型项:

public T this[int index] => data[index];

使用泛型方法,许多基本算法就可以用通用方式实现了。以下是交换两个任意类型 T 的变量值的泛型方法:

static void Swap<T> (ref T a, ref T b)
{
	T temp = a;
	a = b;
	b = temp;
}

通常调用泛型方法不需要提供类型参数,因为编译器可以隐式推断得出。如果有二义性,则可以用下面的方式调用泛型方法:

Swap<int> (ref x, ref y);

3.9.4 声明类型参数

只要类型参数的 数量 不同,泛型类型名和泛型方法的名称就可以进行重载。例如,下面的三个类型名称不会冲突:

class A        { }
class A<T>     { }
class A<T1, T2> { }

3.9.5 typeof 和未绑定泛型类型

C# 中有且只有 typeof ​运算符可以在未绑定类型时,获取泛型类型(Type 实例):

Type a1 = typeof (A<>);   // 未绑定的type (注意类型参数)。
Type a2 = typeof (A<,>);  // 使用多个逗号表示多个类型参数.

也可以使用 typeof ​运算符指定封闭的类型:

Type a3 = typeof (A<int,int>);

3.9.6 泛型的默认值

default ​关键字可用于获取泛型类型参数的默认值。

3.9.7 泛型的约束

泛型中可用约束(6 种)如下:

where T : base-class    // 基类约束
where T : interface     // 接口约束
where T : class         // 引用类型约束
where T : struct        // 值类型约束(不包括可空类型)
where T : new()         // 必须有无参构造器
where U : T             // 泛型类型约束,U必须是类型T或T的派生类

约束可以应用在方法或者类型定义这些可以定义类型参数的地方。

3.9.8 继承泛型类型

子类继承泛型类,可以做到:

  • 保持基类泛型参数开放
  • 封闭基类泛型参数
  • 引入新的类型参数
  • 定义新的类型名称

泛型类和非泛型类一样都可以派生子类。并且子类中仍可以令基类中类型参数保持开放,如下所示:

class Stack<T>                   { /*...*/ }
class SpecialStack<T> : Stack<T> { /*...*/ }

子类也可以用具体的类型来封闭泛型参数:

class IntStack : Stack<int>  { /*...*/ }

子类型还可以引入新的类型参数:

class List<T>                     { /*...*/ }
class KeyedList<T,TKey> : List<T> { /*...*/ }

C7.0 核心技术指南 第7版.pdf - p168 - C7.0 核心技术指南 第 7 版-P168-20240125095634

3.9.9 自引用泛型声明

一个类型可以使用自身类型作为具体类型来封闭类型参数:

public interface IEquatable<T> { bool Equals (T obj); }

public class Balloon : IEquatable<Balloon> {
    public bool Equals (Balloon b) { /*...*/ }
}

下面的写法也是合法的:

class Foo<T> where T : IComparable<T> { ... }
class Bar<T> where T : Bar<T> { ... }    // 这种模式被称为“曲奇切割模式(Curiously Recurring Template Pattern, CRTP)”

3.9.10 静态数据

静态数据对于每一个封闭的类型来说都是 唯一 的:

class Bob<T> { public static int Count; }

static void Main()
{
	Console.WriteLine (++Bob<int>.Count);     // 1
	Console.WriteLine (++Bob<int>.Count);     // 2
	Console.WriteLine (++Bob<string>.Count);  // 1
	Console.WriteLine (++Bob<object>.Count);  // 1
}

3.9.11 类型参数的转换

C#的类型转换有多种:

  1. 数值 转换
  2. 引用 转换
  3. 装箱/拆箱 转换
  4. 自定义 转换(运算符重载)

泛型参数编译时 类型 尚未确定,类型转换会出现 二义性 ,此时编译器会产生一个错误(CS0030)。

二义性常见场景 1:执行引用转换
StringBuilder Foo<T> (T arg)
{
	if (arg is StringBuilder)
		return (StringBuilder) arg;   // Will not compile: CS0030 Cannot convert T to StringBuilder
    ...
}

上述代码因类型 T 不确定,(StringBuilder)arg​​ 的转换类型可能是** 引用 转换,也可能是 自定义 转换**,转换具有二义性,因此编译报错。

解决该问题的方式有二:

  • 改用 as 运算符
StringBuilder sb = arg as StringBuilder;
  • 先将其转换为 object 类型
if (arg is StringBuilder)
    return (StringBuilder) (object) arg;
二义性常见场景 2:执行拆箱转换
int Foo<T> (T x) => (int) x;

上述代码可能是** 拆箱 转换 数值 转换 自定义 转换**,编译时会报错。

解决方式有一:

  • 先将其转换为 object类型
int Foo<T> (T x) => (int)(object) x;

3.9.12 协变

协变:对于某个泛型类型 G<T>​,如果 A​ 是 B​ 的子类,且 G<A> ​ 也被认为是 G<B> ​ 的子类型,那么 G​ 被称为在其泛型参数 T​ 上是协变的。

仅有 泛型接口数组 支持协变。

例如,IFoo<T> ​类型如果能够满足以下条件,则 IFoo<T> ​拥有协变参数 T​:

IFoo<string> s = ...;
IFoo<object> b = s;

注意,协变和逆变都是指“** 隐式引用转换 ​”,数值转换装箱转换自定义转换**均不包含在内。

3.9.12.1 可变性不是自动的

我们现有如下类型:

class Animal {}
class Bear : Animal {}
class Camel : Animal {}

如下代码无法通过编译,这种约束妨碍了复用性:

Stack<Bear> bears = new Stack<Bear>();
Stack<Animal> animals = bears;			// Compile-time error
public static void Wash (Stack<Animal> animals) { /*...*/ }

static void Main() {
	Stack<Bear> bears = new Stack<Bear>();
	Wash (bears);				// Will not compile!
}

我们可以定义一个 带有约束Wash ​方法:

public static void Wash<T>(Stack<T> animals) where T : Animal { /*...*/ }

这段代码,Wash​ 方法的工作原理不是基于协变,而是基于​** 泛型方法的类型推断 **。Stack<T>​ 类本身不支持协变,因为它不是一个协变的接口或委托。代码中的泛型方法可以接受任何符合约束的 Stack<T>​,这里的 T​ 在调用时被推断为 Bear​。

3.9.12.2 数组

由于历史原因,数组类型支持协变(元素需为引用类型)。不过也有缺点,运行时元素进行 赋值 可能发生错误:

Bear[] bears = new Bear[3];
Animal[] animals = bears;     // OK

animals[0] = new Camel();     // Runtime error

3.9.12.3 声明协变类型参数

在接口和委托的类型参数上指定 out 修饰符 可将其声明为协变参数。和数组不同,这个修饰符保证了协变类型参数是完全类型安全的。

为了阐述这一点,我们假定 Stack<T>​ 类实现了如下的接口:

public interface IPoppable<out T> { T Pop(); }

T​​​ ​上的 out​​​ ​修饰符表明了 T​​​ ​只用于输 的位置(例如方法的 返回值 )。** out 修饰符将类型参数标记为协变参数**,并且可以进行如下的操作:

var bears = new Stack<Bear>();
bears.Push (new Bear());
// Bears implements IPoppable<Bear>. We can convert to IPoppable<Animal>:
IPoppable<Animal> animals = bears;   		// Legal
Animal a = animals.Pop();

bear​ ​到 animal​ ​的转换是由编译器保证的,因为类型参数具有协变性。在这种情况下,若试图将 Camel​ ​实例入栈,则编译器会阻止这种行为。因为 T​ ​只能在输 位置出现,因此不可能将 Camel​ ​类输入接口中。

C7.0 核心技术指南 第7版.pdf - p172 - C7.0 核心技术指南 第 7 版-P172-20240125124405

如果在输入位置,例如方法的参数或可写属性,使用协变参数则会发生编译时错误。

3.9.13 逆变

逆变与协变相反:对于某个泛型类型 G<T>​,如果 A​ 是 B​ 的子类,且 G<B> ​ 也被认为是 G<A> ​ 的子类型,那么 G​ 被称为在其泛型参数 T​ 上是逆变的。

逆变类型仅应用在输 位置上,并用 ** in 修饰符**标记。以下扩展了之前的例子,如果 Stack<T>​​ ​实现了如下的接口:

public interface IPushable<in T> { void Push (T obj); }

则以下语句是合法的:

IPushable<Animal> animals = new Stack<Animal>();
// 注意,此处将 Bear 视为 Animal 的基类
IPushable<Bear> bears = animals;    // Legal
bears.Push (new Bear());

这有什么用呢?此处用 IComparer ​接口进行举例:

public interface IComparer<in T> {
  int Compare (T a, T b);
}
// 获取一个比较器,该比较器使用 IComparable
IComparer<IComparable> objectComparer = Comparer<IComparable>.Default;
// 此处进行逆变,因逆变只能通过接口进行,此处用 IComparer<string>,而非 Comparer<string>
IComparer<string> stringComparer = objectComparer;
// 使用比较器进行比较
int result = stringComparer.Compare("Brett", "Jemaine");
result.Dump();

可以注意到,上述代码将 IComparer<IComparable>​ 视作 IComparer<string>​ 的 类,stringComparer.Compare​ 用到了 IComparable.Compare​ 方法,逆变前后的类型(IComparable​、string​)都支持该方法,因此可以逆变。

posted @   hihaojie  阅读(11)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?
点击右上角即可分享
微信分享提示