# 在 C# 中创建类型
# 类
表达式体方法
以下仅由一个表达式构成的方法:
int Foo(int x){ return x*2; } |
可以用表达式体方法简洁地表示,用胖箭头来取代花括号和 return 关键字:
int Foo(int x)=> x*2; |
表达式体函数也可以用 void 作为返回类型:
void Foo(int x)=> Console.WriteLine(x); |
# 实例构造器
实例构造器支持以下的修饰符:
- 访问权限修饰符:public internal private protected
- 非托管代码修饰符:unsafe extern
重载构造器
using System; | |
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; } | |
} |
当构造器调用另一个构造器时,被调用的构造器先执行。
注意:表达式内不能使用 this 引用,例如,不能调用实例方法(这是强制性的。由于这个对象还没通过构造器初始化完毕,因此调用任何方法都有可能失败)。但是表达式可以调用静态方法。
构造器和字段的初始化顺序
class Player | |
{ | |
int shields = 50; // Initialized first | |
int health = 100; // Initialized second | |
} |
字段的初始化按声明的先后顺序,在构造器之前执行。
解构器
解构器(或称之为解构方法)就像构造的反过程,名字必须为 Deconstruct,并且拥有一个或多个 out 参数。
class Rectangle | |
{ | |
public readonly float Width, Height; | |
public Rectangle(float width, float height) => (Width, Height) = (width, height); | |
public void Deconstruct(out float width, out float height) | |
{ | |
width = Width; | |
height = Height; | |
} | |
} | |
class Test | |
{ | |
static void Main(string[] args) | |
{ | |
var rect = new Rectangle(3, 4); | |
//(float width, float height) = rect; | |
//float width, height; | |
//rect.Deconstruct(out width, out height); // Deconstruction | |
//rect.Deconstruct(out var width, out var height); | |
//(var width, var height) = rect; | |
var (width, height) = rect; | |
//var (_, height) = rect; | |
//Console.WriteLine(height); | |
Console.WriteLine(width + " " + height); // 3 4 | |
} | |
} |
# 对象初始化器
为了简化对象的初始化,可以在调用构造器之后直接通过对象初始化器设置对象的可访问字段或属性。
public class Bunny | |
{ | |
public string Name; | |
public bool LikesCarrots; | |
public bool LikesHumans; | |
public Bunny () {} | |
public Bunny (string n) { Name = n; } | |
} | |
// Note parameterless constructors can omit empty parentheses | |
Bunny b1 = new Bunny { Name="Bo", LikesCarrots=true, LikesHumans=false }; | |
Bunny b2 = new Bunny ("Bo") { LikesCarrots=true, LikesHumans=false }; | |
Bunny temp1 = new Bunny(); // temp1 is a compiler-generated name | |
temp1.Name = "Bo"; | |
temp1.LikesCarrots = true; | |
temp1.LikesHumans = false; | |
Bunny b1 = temp1; | |
Bunny temp2 = new Bunny ("Bo"); | |
temp2.LikesCarrots = true; | |
temp2.LikesHumans = false; | |
Bunny b2 = temp2; |
# 引用
略
# 属性
属性支持以下的修饰符:
- 静态修饰符:static
- 访问权限修饰符:public internal private protected
- 继承修饰符:new virtual abstract override sealed
- 非托管代码修饰符:unsafe extern
自动属性
属性最常见的实现方式是使用 get 访问器和 set 访问器读写私有字段(该字段与属性类型相同)。
public class Stock | |
{ | |
... | |
public decimal CurrentPrice { get; set; } | |
} |
编译器会自动生成一个后台私有字段,该字段的名称由编译器生成且无法引用。如果希望属性对其他类型暴露为只读属性,则可以将 set 访问器标记为 private 或 protected。自动属性是在 C#3.0 中引入的。
CLR 属性的实现
C# 属性访问器在内部会编译名为 get_XXX 和 set_XXX 的方法:
public decimal get_CurrentPrice {...} | |
public void set_CurrentPrice (decimal value) {...} |
简单的非虚属性访问器会被 JIT(即使)编译器内联编译,消除了属性和字段访问键的性能差距。内联是一种优化方法,它用方法的函数体代替方法调用。
Windows Runtime 库中的属性则会被编译器转换为 put_XXX 作为属性命名约定而非 set_XXX。
# 索引器
索引器为访问类或结构体中封装的列表或字典型数据元素提供了自然的访问接口。使用索引器的语法就像使用数组一样,不同之处在于索引参数可以是任意类型。
string s = "hello"; | |
Console.WriteLine (s[0]); // 'h' | |
Console.WriteLine (s[3]); // 'l' | |
string s = null; | |
Console.WriteLine (s?[0]); // Writes nothing; no error. |
索引器的实现
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 { ... } | |
} | |
public string this [int wordNum] => words [wordNum]; |
CLR 索引器的实现
索引器在内部会编译名为 get_Item 和 set_Item 的方法,如下所示:
public string get_Item (int wordNum) {...} | |
public void set_Item (int wordNum, string value) {...} |
在索引器中使用索引和范围(C#)
在自定义类中,可以在索引器参数中使用 Index 和 Range 类型来支持索引和范围操作。
public string this [Index index] => words [index]; | |
public string[] this [Range range] => words [range]; | |
Sentence s = new Sentence(); | |
Console.WriteLine (s [^1]); // fox | |
string[] firstTwoWords = s [..2]; // (The, quick) |
# 静态构造器
每个类型的静态构造器只会执行一次,并不是每个实例执行一次。一个类型只能定义一个静态构造器,名称必须和类型同名,且没有参数:
class Test | |
{ | |
static Test() { Console.WriteLine("Type Initialized"); } | |
} |
运行时将在类型使用之前调用静态构造器,以下两种行为可以触发静态构造器执行:
- 实例化类型
- 访问类型的静态成员
静态构造器只支持两个修饰符:unsafe 和 extern
如果静态构造器抛出了未处理的异常(见第 4 章),则该类型在整个应用程序声明周期内都是不可用的。
静态构造器和字段初始化顺序
静态字段初始化器会在调用静态构造器前运行。如果类型没有静态构造器,字段会在类型被使用之前或者在运行时中更早的时间进行初始化。
静态字段初始化器按照字段声明的先后顺序运行。
class Foo | |
{ | |
public static Foo Instance = new Foo(); | |
public static int X = 3; | |
Foo() { Console.WriteLine(X); } | |
} | |
class StaticConstructors | |
{ | |
static void Main() { Console.WriteLine(Foo.X); } | |
} |
# 静态类
类可以标记为 static,表明它必须只能够由 static 成员组成,并且不能派生子类。 System.Console
和 System.Math
类就是静态类的最好示例。
# 终结器
终结器是只能够在类中使用的方法。该方法在垃圾回收器回收未引用对象占用的内存前调用。终结器的语法是类的名称加上~前缀。
class Class1{ | |
~Class1(){ | |
... | |
} | |
} |
事实上,这是 C# 语言重写 Object 类的 Finalize 方法的语言。编译器会将其扩展为如下声明:
protected override void Finalize(){ | |
... | |
base.Finalize(); | |
} |
终结器允许使用以下的修饰符:
- 非托管代码修饰符:unsafe
# 分部类型和方法
分布类型(partial type)允许一个类型分开进行定义,典型的做法是分开在多个文件中。分布类型使用的常见场景是从其他源文件自动生成分布类(例如从 Visual Studio 模板或设计器),而这些类任然需要额外手动编写方法。
// PaymentFormGen.cs - auto-generated | |
partial class PaymentForm { ... } | |
// PaymentForm.cs - hand-authored | |
partial class PaymentForm { ... } |
每一个部分必须包含 partial 声明。
分布类型的各个组成部分不能包含冲突的成员,例如具有相同参数的构造器。分部类型完全由编译器处理,因此各部分在编译时必须可用,并且必须编译在同一个程序集中。可以在多个分布类中声明中指定基类,只要基类是同一个基类即可。此外,每一个分部类型组成部分可以独立指定实现的接口。
编译器并不保证分部类型声明中各个组成部分之间的字段初始化顺序。
分部方法
分部类型可以包含分部方法(partial method)。这些方法能够令自动生成的分部类型为手动编写的代码提供自定义钩子(hook)。
partial class PaymentForm // In auto-generated file | |
{ | |
... | |
partial void ValidatePayment (decimal amount); | |
} | |
partial class PaymentForm // In hand-authored file | |
{ | |
... | |
partial void ValidatePayment (decimal amount) | |
{ | |
if (amount > 100) | |
... | |
} | |
} |
分部方法由两部分组成:定义和实现。定义一般需要代码生成器生成,而实现一般需要手动编写。如果没有提供方法的实现,分部方法的定义会被编译器清除(调用它的代码部分也一样)。这样,自动生成的代码既可以提供钩子又不必单行代码过于臃肿。分部方法返回值类型必须是 void,且该方法是隐式的 private 方法。
# nameof
运算符
nameof
运算符返回任意符号的字符串名称(类型、成员、变量等)。
int count = 123; | |
string name = nameof (count); // name is "count" |
# 继承
# 多态
引用是多态的,意味着 x 类型的变量可以指向 x 子类的对象。多态之所以能够实现,是因为子类具有基类的全部特征,反过来则不正确。
# 类型转换和引用转换
as 运算符
as 运算符再向下类型转换出错时返回 null(而不是抛出异常):
Asset a = new Asset(); | |
Stock s = a as Stock; // s is null; no exception thrown |
如果不用判断结果是否为 null,那么更推荐使用类型转换。因为如果发生错误,类型转换会抛出描述更加清晰的异常。
int shares = ((Stock)a).SharesOwned; // Approach #1 | |
int shares = (a as Stock).SharesOwned; // Approach #2 |
as 和类型转换运算符也可以用来实现向上类型转换,但是不常用。因为隐式转换就已经足够了。
is 运算符
is 运算符用于检测变量是否满足特定的模式。C# 支持若干模式,其中最重要的模式是类型模式。在这种模式下,is 运算符后跟类型的名称。
在类型模式上下文中,is 运算符检查引用的转换是否能够成功,即对象是否从某个特定的类派生(或是实现某个接口)。
if (a is Stock) | |
Console.WriteLine (((Stock)a).SharesOwned); |
引入模式变量
在使用 is 运算符时可以引入一个变量:
if (a is Stock s) | |
Console.WriteLine (s.SharesOwned); |
上述代码等价于:
Stock s; | |
if (a is Stock) | |
{ | |
s = (Stock) a; | |
Console.WriteLine (s.SharesOwned); | |
} |
引用的变量可以 “立即” 使用,因此以下代码是合法的:
if (a is Stock s && s.SharesOwned > 100000) | |
Console.WriteLine ("Wealthy"); |
同时,引入的变量即使在 is 表达式之外仍然在作用域内。例如:
if (a is Stock s && s.SharesOwned > 100000) | |
Console.WriteLine ("Wealthy"); | |
Else | |
s = new Stock(); // s is in scope | |
Console.WriteLine (s.SharesOwned); // Still in scope |
# 虚函数成员
子类可以重写标识为 virtual 的函数以提供特定的实现。方法、属性、索引器和事件都可以声明为 virtual:
public class Asset | |
{ | |
public string Name; | |
public virtual decimal Liability => 0; // Expression-bodied property | |
} |
Liabiility => 0 是 {get { return 0;} } 的简写。
子类通过应用 override 修饰符重写虚方法:
public class Stock : Asset | |
{ | |
public long SharesOwned; | |
} | |
public class House : Asset | |
{ | |
public decimal Mortgage; | |
public override decimal Liability => Mortgage; | |
} |
从构造器调用虚方法有潜在的危险性,因为编写子类的人在重写方法的时候未必知道现在正在操作一个未完成实例化的对象。换言之,重写的方法恒可能最终会访问到一些方法和属性,而这些方法或属性依赖的字段还未被构造器初始化。
# 抽象类和抽象成员
抽象类中可以定义抽象成员,抽象成员和虚成员相似,只不过抽象成员不提供默认的实现。除非子类也声明为抽象类,否则其实现必须由子类提供:
public abstract class Asset | |
{ | |
// Note empty implementation | |
public abstract decimal NetValue { get; } | |
} | |
public class Stock : Asset | |
{ | |
public long SharesOwned; | |
public decimal CurrentPrice; | |
// Override like a virtual method. | |
public override decimal NetValue => CurrentPrice * SharesOwned; | |
} |
# 隐藏继承成员
基类和子类可能定义相同的成员,例如:
public class A { public int Counter = 1; } | |
public class B : A { public int Counter = 2; } |
类 B 中的 Counter 字段隐藏了类 A 中的 Counter 字段。因此,编译器会产生一个警告,并采用下面的方法避免这种二义性:
- A 的引用(在编译时)绑定到 A.counter。
- B 的引用(在编译时)绑定到 B.counter。
有时需要故意隐藏一个成员。此时可以在子类的成员中使用 new 修饰符。new 修饰符仅用于阻止编译器发出警告,写法如下:
public class A { public int Counter = 1; } | |
public class B : A { public new int Counter = 2; } |
C# 在不同的上下文中的 new 关键字拥有完全不同的含义。特别注意 new 运算符和 new 修饰符是不同的。
new 和重写
public class BaseClass | |
{ | |
public virtual void Foo() { Console.WriteLine ("BaseClass.Foo"); } | |
} | |
public class Overrider : BaseClass | |
{ | |
public override void Foo() { Console.WriteLine ("Overrider.Foo"); } | |
} | |
public class Hider : BaseClass | |
{ | |
public new void Foo() { Console.WriteLine ("Hider.Foo"); } | |
} | |
Overrider over = new Overrider(); | |
BaseClass b1 = over; | |
over.Foo(); // Overrider.Foo | |
b1.Foo(); // Overrider.Foo | |
Hider h = new Hider(); | |
BaseClass b2 = h; | |
h.Foo(); // Hider.Foo | |
b2.Foo(); // BaseClass.Foo |
# 封装函数和类
重写的函数成员可以使用 sealed 关键字封闭其实现,防止其他的子类再次重写。
public sealed override decimal Liability { get { return Mortgage; } } |
# base 关键字
base 关键字和 this 关键字很类似。它由两个重要目的:
- 从子类访问重写的基类函数成员。
- 调用基类的构造器。
# 构造器和继承
子类必须声明自己的构造器。派生类可以访问基类的构造器,但是并非自动继承。
base 关键字和 this 关键字很像,但 base 关键字调用的是基类的构造器。
基类的构造器总是先执行,这保证了基类的初始化发生在子类特定的初始化之前。
隐式调用基类的无参数构造器
如果子类的构造器省略 base 关键字,那么基类的无参构造器将被隐式调用:
public class BaseClass | |
{ | |
public int X; | |
public BaseClass() { X = 1; } | |
} | |
public class Subclass : BaseClass | |
{ | |
public Subclass() { Console.WriteLine (X); } // 1 | |
} |
如果基类没有可访问的无参数构造器,子类的构造器中就必须使用 base 关键字。
构造器和字段初始化的顺序
When an object is instantiated, initialization takes place in the following order: | |
1. From subclass to base class: | |
a. Fields are initialized | |
b. Arguments to base-class constructor calls are evaluated | |
2. From base class to subclass: | |
a. Constructor bodies execute | |
The following code demonstrates: | |
public class B | |
{ | |
int x = 1; // Executes 3rd | |
public B (int x) | |
{ | |
... // Executes 4th | |
} | |
} | |
public class D : B | |
{ | |
int y = 1; // Executes 1st | |
public D (int x) | |
: base (x + 1) // Executes 2nd | |
{ | |
... // Executes 5th | |
} | |
} |
# 重载和解析
继承对方法的重载有着特殊的影响。请考虑以下两个重载:
static void Foo (Asset a) { } | |
static void Foo (House h) { } |
当重载被调用时,类型最明确的优先匹配:
House h = new House (...);
Foo(h); // Calls Foo(House)
Asset a = new House (...);
Foo(a); // Calls Foo(Asset)
具体调用哪个重载是在编译器静态决定的而非运行时决定。
如果把 Asset 类转换为 dynamic,则会在运行时决定调用哪个重载。这样就会基于对象的实际类型进行选择:
Asset a = new House (...); | |
Foo ((dynamic)a); // Calls Foo(House) |
# Object 类型
object 时引用类型,承载了类的优点。尽管如此,int 等值类型也可以和 object 类型相互转换并加入栈中。C# 这种特性称为类型一致化。
当值类型和 object 类型相互转换时,公共语言运行时(CLR)必须进行一些特定的工作来对接值类型和引用类型在语义上的差异。这个过程称为装箱(boxing)和拆箱(unboxing)。
# 装箱和拆箱
装箱是将值类型实例转换为引用类型实例的行为。引用类型可以时 object 类型或接口。
int x = 9; | |
object obj = x; // Box the int |
拆箱操作刚好相反,它把 object 类型转换成原始的值类型:
int y = (int)obj; // Unbox the int
拆箱需要显示类型转换。运行时将检查提供的值类型和真正的对象类型是否匹配,并在检查类型出错的时候抛出 InvalidCastException。
object obj = 3.5; // 3.5 is inferred to be of type double | |
int x = (int) (double) obj; // x is now 3 |
在上例中,(double) 时拆箱操作而 (int) 是数值转换操作。
装箱转换对系统提供一致性的数据类型至关重要。但这个体系并不是完美的;在 3.9 节中会介绍数组和泛型的变量只能支持引用转换,不能支持装箱转换:
object[] a1 = new string[3]; // Legal | |
object[] a2 = new int[3]; // Error |
装箱拆箱中的复制语义
装箱是把值类型的实例复制到新对象中,而拆箱是吧对象的内容复制回值类型的实例中。
int i = 3; | |
object boxed = i; | |
i = 5; | |
Console.WriteLine (boxed); // 3 |
# 静态和运行时类型检查
C# 程序在静态(编译时)和运行时(CLR)都会执行类型检查。
运行时可以进行类型检查是因为堆上的每一个对象都在内部存储了类型标识,这个标识可以通过调用 object 类型的 GetType 方法得到。
# GetType 方法和 typeof 运算符
C# 中的所有类型在运行时都会表示为 System.Type 类的实例。有两个基本方法可以获得 System.Type 对象:
- 在类型实例上调用 GetType 方法
- 在类型名称上使用 typeof 运算符
GetType 在运行时计算,而 typeof 在编译时静态计算(如果是泛型类型参数,那么它将由即时编译器解析)。
System.Type 拥有诸多属性,例如类型的名称、程序集、基类型等属性。
using System; | |
public class Point { public int X, Y; } | |
class Test | |
{ | |
static void Main() | |
{ | |
Point p = new Point(); | |
Console.WriteLine (p.GetType().Name); // Point | |
Console.WriteLine (typeof (Point).Name); // Point | |
Console.WriteLine (p.GetType() == typeof(Point)); // True | |
Console.WriteLine (p.X.GetType().Name); // Int32 | |
Console.WriteLine (p.Y.GetType().FullName); // System.Int32 | |
} | |
} |
System.Type 同时还是运行时反射模型的访问入口。
# ToString 方法
ToString 方法返回类型实例的默认文本描述。所有内置类型都重写了该方法。
当直接在值类型上调用 ToString 这样的 object 成员时,若该成员是重写的,则不会发生装箱。只有进行类型转换时才会执行装箱操作。
int x = 1; | |
string s1 = x.ToString(); // Calling on nonboxed value | |
object box = x; | |
string s2 = box.ToString(); // Calling on boxed value |
# object 的成员列表
public class Object | |
{ | |
public Object(); | |
public extern Type GetType(); | |
public virtual bool 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(); | |
} |
# 结构体
结构体和类相似,不同之处在于:
- 结构体是值类型,而类型是引用类型
- 结构体不支持继承(除了隐式派生自 object 类型,或更精准地说,是派生自 System.valueType)。
除了以下内容,结构体可以包含类的所有成员:
- 无参数的构造器
- 字段初始化器
- 终结器
- 虚成员或 protected 成员
由于结构体是值类型,因此其实例不需要在堆上实例化,创建一个类型的多个实例就更加高效了。结构体是值类型,因而其实例不能为 null。结构体对象的默认值是一个空值实例,即其所有的字段均为空值(均为其默认值)。
# 结构体的构造语义
需要之处的是结构体不支持字段初始化器
在构造结构体时使用 default 关键字的效果和调用隐式的无参数构造器的效果时一样的:
Point p1 = default; |
以下实例包含三个编译时错误:
public struct Point | |
{ | |
int x = 1; // Illegal: field initializer | |
int y; | |
public Point() {} // Illegal: parameterless constructor | |
public Point (int x) {this.x = x;} // Illegal: must assign field y | |
} |
如果把 struct 替换为 class,则以上写法都合法。
# 只读结构体和只读函数
# ref 结构体
# 访问权限修饰符
为了提高封装性,类型或类型成员可以在声明中添加以下六个访问权限修饰符之一来限定其他类型和其他程序集对它的访问。
- public:完全访问权限。枚举类型成员或接口成员隐含的可访问性。
- internal:仅可以在程序集内访问,或供友元程序集访问。这是非嵌套类型的默认可访问性。
- private:仅可以在包含类型中访问。这是类或者结构体成员的默认可访问性。
- protected:仅可以在包含类型或子类中访问。
- protected internal:protected 和 internal 可访问性的并集。protected internal 修饰符的成员在任意一种修饰符限定下都能够访问。
- private protected:从 C#7.2 开始支持,protected 和 internal 可访问性的交集。若一个成员是 private protected 的,那么该成员只能够在其类型中或被其相同程序集中的子类型访问(它的可访问性比 protected 和 internal 都低)。
# 友元程序集
# 可访问性上限
# 访问权限修饰符的限制
当重写基类的函数时,重写函数的可访问性必须一致,例如:
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。这是上述规则中的一个例外情况。
# 接口
接口和类相似,但接口只提供行为定义而不会持有任何状态(数据),因此:
- 接口只能定义函数而不能定义字段
- 接口的成员都是隐式抽象的(虽然 C# 8 支持在接口中声明非抽象函数,但这应当视为一种特殊情况。3.6.6 节中详细接受该特性)
- 一个类(或者结构体)可以实现多个接口。而一个类只能够继承一个类,结构体则完全不支持继承(只能从 System.ValueType 派生)
接口成员总是隐式 public 的,并且不能用访问权限修饰符声明。
# 显式接口实现
当实现多个接口时,有时会出现成员签名的冲突。显式实现(explicitly implementing)接口成员可以解决冲突。
调用显式实现成员的唯一方式是先将其转换为对应的接口:
Widget w = new Widget(); | |
w.Foo(); // Widget's implementation of I1.Foo | |
((I1)w).Foo(); // Widget's implementation of I1.Foo | |
((I2)w).Foo(); // Widget's implementation of I2.Foo |
另一个使用显式接口成员的原因是隐藏那些高度定制化的或对类的正常使用干扰很大的接口成员。
# 实现接口的需成员
默认情况下,隐式实现的接口成员是密封的。为了重写,必须在基类中将其标识为 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"); | |
} |
显式实现的接口成员不能标识为 virtual,也不能实现通常意义的重写,但是它可以被重新实现。
# 在子类中重新实现接口
# 接口和装箱
将结构体转换为接口会引发装箱。而调用结构体的隐式实现实现接口成员不会引发装箱:
interface I { void Foo(); } | |
struct S : I { public void Foo() {} } | |
... | |
S s = new S(); | |
s.Foo(); // No boxing. | |
I i = s; // Box occurs when casting to interface. | |
i.Foo(); |
# 默认接口成员(C# 8)
从 C# 8 开始,可以在接口成员中添加默认实现,而该成员不必必须进行实现:
interface ILogger | |
{ | |
void Log (string text) => Console.WriteLine (text); | |
} |
默认实现永远是显式的。因而假设一个类实现了 ILogger 接口但并未定义 Log 方法,那么要调用 Log 方法,必须通过接口来进行。
class Logger : ILogger { } | |
... | |
((ILogger)new Logger()).Log ("message"); |
除此之外,接口中还能定义静态成员(包括静态字段)。接口的默认实现可以访问这些静态成员:
interface ILogger | |
{ | |
void Log (string text) => | |
Console.WriteLine (Prefix + text); | |
static string Prefix = ""; | |
} |
由于接口成员是隐式 public 成员,因此在外部访问其静态成员也是可行的:
ILogger.Prefix = "File log: "; |
如需限制这一行为,可在接口的静态成员上添加访问权限修饰符(例如 private、protected 和 internal)。
在接口中声明实例字段仍然是非法的。这和接口的原则 -- 接口用于定义行为而非持有状态 -- 是一致的。
# 枚举类型
枚举类型是一种特殊的值类型。我们能够在该类型中定义一组命名的数值常量。
public enum BorderSide { Left, Right, Top, Bottom } | |
BorderSide topSide = BorderSide.Top; | |
bool isTop = (topSide == BorderSide.Top); // true |
每一个枚举成员都对应一个整数。在默认情况下:
- 对应的数值是 int 类型的
- 安装枚举成员的声明顺序,自动按照 1、2、3…… 进行常量赋值
当然,也可以指定其他的整数类型代替默认类型,例如:
public enum BorderSide : byte | |
{ Left=1, Right, Top=10, Bottom } |
编译器还支持显式指定部分枚举成员。没有指定的枚举成员,在最后一个显式指定的值基础上递增。
# 枚举类型转换
在枚举表达式中,编译器会特殊对待数值字面量 0。它不需要进行显式转换:
BorderSide b = 0; // No cast required | |
if (b == 0) ... |
# 标志枚举类型
枚举类型的成员可以合并。为了避免混淆,合并枚举类型的成员要显式指定值,典型的值为 2 的幂次。例如:
[Flags] | |
enum BorderSides { None=0, Left=1, Right=2, Top=4, Bottom=8 } | |
or: | |
enum BorderSides { None=0, Left=1, Right=1<<1, Top=1<<2, Bottom=1<<3 } | |
BorderSides leftRight = BorderSides.Left | BorderSides.Right; | |
if ((leftRight & BorderSides.Left) != 0) | |
Console.WriteLine ("Includes Left"); // Includes Left | |
string formatted = leftRight.ToString(); // "Left, Right" | |
BorderSides s = BorderSides.Left; | |
s |= BorderSides.Right; | |
Console.WriteLine (s == leftRight); // True | |
s ^= BorderSides.Right; // Toggles BorderSides.Right | |
Console.WriteLine (s); // Left |
按照惯例,当枚举类型的成员可以合并时,其枚举类型一定要应用 Flags 特性。如果声明了一个没有标注 Flags 特性的枚举类型,其成员依然可以合并,但若在该枚举类型实例上调用 toString 方法,则会输出一个数值而非一组名字。
一般来说,合并枚举类型通常用复数名词而不是单数形式。
# 类型安全性问题
# 嵌套类型
嵌套类型是声明在另一个类型内部的类型:
public class TopLevel | |
{ | |
public class Nested { } // Nested class | |
public enum Color { Red, Blue, Tan } // Nested enum | |
} |
嵌套类型有如下的特征:
- 可以访问包含它的外层类型中的私有成员,以及外层类所能访问的所有内容。
- 可以在声明上使用所有的访问权限修饰符,而不限于 public 和 internal
- 嵌套类型的默认可访问性是 private 而不是 internal
- 从外层类以外访问嵌套类型,需要使用外层类名称进行限定(就像访问静态成员一样)
TopLevel.Color color = TopLevel.Color.Red; |
public class TopLevel | |
{ | |
static int x; | |
class Nested | |
{ | |
static void Foo() { Console.WriteLine (TopLevel.x); } | |
} | |
} |
public class TopLevel | |
{ | |
protected class Nested { } | |
} | |
public class SubTopLevel : TopLevel | |
{ | |
static void Foo() { new TopLevel.Nested(); } | |
} |
public class TopLevel | |
{ | |
public class Nested { } | |
} | |
class Test | |
{ | |
TopLevel.Nested n; | |
} |
如果使用嵌套类型的主要原因是为了避免命名空间类型定义杂乱无章,那么可以考虑使用嵌套命名空间。使用嵌套类型的原因应当是利用它较强的访问控制能力,或者是为了嵌套的类型必须访问外层类型的私有成员。
# 泛型
C# 有两种不同的机制来编写类型可复用的代码:继承和泛型。但继承的复用性来自基类,而泛型的复用性是通过带有占位符的模板类实现的。和继承相比,泛型能够提高类型的安全性,减少类型的转换和装箱。
C# 的泛型和 C++ 的模板是相似的概念,但它们的工作方式不同。
# 泛型类型
泛型类型中声明的类型参数(占位符类型)需要由泛型类型的消费者(即提供类型参数的一方)来填充。
public class Stack<T> | |
{ | |
int position; | |
T[] data = new T[100]; | |
public void Push (T obj) => data[position++] = obj; | |
public T Pop() => data[--position]; | |
} | |
// We can use Stack<T> as follows: | |
var stack = new Stack<int>(); | |
stack.Push (5); | |
stack.Push (10); | |
int x = stack.Pop(); // x is 10 | |
int y = stack.Pop(); // y is 5 |
技术上,我们称 Stack<T> 是开放类型,称 Stack<int > 是封闭类型。在运行时,所有的泛型实例都是封闭的,占位符已经被类型填充。
# 为什么需要泛型
泛型是为了代码能够跨类型复用而设计的。
# 泛型方法
泛型方法在方法的签名中声明类型参数。
static void Swap<T> (ref T a, ref T b) | |
{ | |
T temp = a; | |
a = b; | |
b = temp; | |
} | |
// Swap<T> is called as follows: | |
int x = 5; | |
int y = 10; | |
Swap (ref x, ref y); |
通常调用泛型方法不需要提供类型参数,因为编译器可以隐式推断出类型信息。如果由二义性,则可以用以下方式调用泛型方法:
Swap<int> (ref x, ref y); |
在泛型中,只有引入类型参数(用尖括号标出)的方法才可归为泛型方法。
微有方法和可以引入类型参数。属性、索引器、事件、字段、构造器、运算符等都不能声明类型参数,虽然它们可以参与使用所在类型中已经声明的类型参数。
# 声明类型参数
可以在声明类、结构体、接口、委托和方法时引入类型参数。其他结构(如属性)虽不能引入类型参数,但可以使用类型参数。
public struct Nullable<T> | |
{ | |
public T Value { get; } | |
} |
只要类型参数的数量不同,泛型类型名和泛型方法的名称就可以进行重载。
class A {} | |
class A<T> {} | |
class A<T1,T2> {} |
# typeof 和未绑定泛型类型
在运行时不存在开放的泛型类型:开放泛型类型将在编译过程中封闭。但运行时可能存在未绑定的泛型类型,这种泛型类型只作为 Type 对象存在。C# 中唯一指定未绑定泛型类型的方式是使用 typeof 运算符:
class A<T> {} | |
class A<T1,T2> {} | |
... | |
Type a1 = typeof (A<>); // Unbound type (notice no type arguments). | |
Type a2 = typeof (A<,>); // Use commas to indicate multiple type args. |
开放泛型类型一般与反射 API 一起使用。
typeof 运算符也可以用于指定封闭的类型:
Type a3 = typeof (A<int,int>); |
或开放类型(当然,他会在运行时封闭):
class B<T> { void X() { Type t = typeof (T); } } |
# 泛型的默认值
default 关键字可用于获取泛型类型参数的默认值。引用类型的默认值为 null,而值类型的默认值是将值类型的所有字段按位设置为 0 的值。
static void Zap<T> (T[] array) | |
{ | |
for (int i = 0; i < array.Length; i++) | |
array[i] = default(T); | |
} |
从 C# 7.1 开始,我们可以在编译器能够进行类型推断的情况下忽略类型参数。因此以上程序的最后一行可以写为:
array[i] = default; |
# 泛型的约束
默认情况下,类型参数可以由任何类型来替换。在类型参数上应用约束可以将类型参数定义为指定的类型参数。以下列出了可用的约束:
where T : base-class // Base-class constraint | |
where T : interface // Interface constraint | |
where T : class // Reference-type constraint | |
where T : class? // (See "Nullable reference types") | |
where T : struct // Value-type constraint (excludes Nullable types) | |
where T : unmanaged // Unmanaged constraint | |
where T : new() // Parameterless constructor constraint | |
where U : T // Naked type constraint | |
where T : notnull // Non-nullable value type, or from C# 8 | |
// a non-nullable reference type. |
约束可以应用在方法或类型定义这些可以定义类型参数的地方。
基类约束要求类型参数必须是子类(或者匹配特定的类);接口约束要求类型参数必须实现特定的接口。这些约束要求类型参数的实例可以隐式转换为相应的类或接口。
类约束和结构体约束规定 T 必须是引用类型或值类型(不能为空)。
C# 7.3 引入了非托管类型约束。该约束是一个增强型的结构体约束。其中 T 必须是一个简单的值类型或该值类型中(递归的)不包含任何引用类型字段
无参数构造器约束要求 T 有一个 public 无参数构造器。
裸类型约束要求一个类型参数必须从另一个类型参数中派生(或匹配)。
严格地说,非托管类型约束指类型参数必须是一个不可空的非托管类型。其中非托管类型指:sbyte、byte、short、ushort、int、uint、long、ulong、char、float、double、decimal 和 bool;枚举类型;指针类型以及任何自定义的不包含任意类型参数的仅仅包含非托管类型的字段和结构体。
# 继承泛型类型
泛型类和非泛型类一样,都可以派生子类。
class Stack<T> {...} | |
class SpecialStack<T> : Stack<T> {...} | |
class IntStack : Stack<int> {...} | |
class List<T> {...} | |
class KeyedList<T,TKey> : List<T> {...} | |
class List<T> {...} | |
class KeyedList<TElement,TKey> : List<TElement> {...} |
# 自引用泛型声明
一个类型可以使用自身类型作为具体类型来封闭类型参数:
public interface IEquatable<T> { bool Equals (T obj); } | |
public class Balloon : IEquatable<Balloon> | |
{ | |
public string Color { get; set; } | |
public int CC { get; set; } | |
public bool Equals (Balloon b) | |
{ | |
if (b == null) return false; | |
return b.Color == Color && b.CC == CC; | |
} | |
} |
以下写法也是合法的:
class Foo<T> where T : IComparable<T> { ... } | |
class Bar<T> where T : Bar<T> { ... } |
# 静态数据
静态数据对每一个封闭的类型来说都是唯一的:
class Bob<T> { public static int Count; } | |
class Test | |
{ | |
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 | |
} | |
} |
# 类型参数和转换
C# 的类型转换运算符可以进行多种类型转换,包括:
- 数值转换
- 引用转换
- 装箱和拆箱转换
- 自定义转换(通过运算符重载)
对于泛型类型参数来说,由于编译时操作数的类型还未确定,上述规则就会出现特殊的清醒。如果导致了二义性,那么编译器就会产生一个错误。
最常见的情形是在执行引用转换时:
StringBuilder Foo<T> (T arg) | |
{ | |
if (arg is StringBuilder) | |
return (StringBuilder) arg; // Will not compile | |
... | |
} |
由于不知道 T 的确切类型,编译器会疑惑你是否希望执行自定义转换。上述问题最简单的解决方案就是改用 as 运算符,由于它不能进行自定义类型转换,因此是没有二义性的:
StringBuilder Foo<T> (T arg) | |
{ | |
StringBuilder sb = arg as StringBuilder; | |
if (sb != null) return sb; | |
... | |
} |
更一般的做法是先将其转换为 object 类型。
return (StringBuilder) (object) arg; |
# 协变
假定 A 可以转换为 B,如果 X<A> 可以转换为 X<B>,那么称 X 有一个协变类型参数。
由于 C# 有协变(covariance)和逆变(contravariance)的概念,所以 “可转换” 意味着可以通过隐式引用转换进行类型转换。而数值转换、装箱转换和自定义转换是不包含在内的。
接口支持协变类型参数(委托也支持协变类型参数),但是类不支持协变类型参数。数组也支持协变(如果 A 可以隐式引用转换为 B,则 A [] 也可以隐式引用转换为 B [])。
在 C# 中引入和强化协变的动机是允许泛型接口和泛型类型(尤其是.NET Core 中定义的那些类型,例如 IEnumerable<T>)像人们期待的那样工作。
可变性不是自动的
class Animal {} | |
class Bear : Animal {} | |
class Camel : Animal {} | |
public class Stack<T> // A simple Stack implementation | |
{ | |
int position; | |
T[] data = new T[100]; | |
public void Push (T obj) => data[position++] = obj; | |
public T Pop() => data[--position]; | |
} | |
// The following fails to compile: | |
Stack<Bear> bears = new Stack<Bear>(); | |
Stack<Animal> animals = bears; // Compile-time error | |
// That restriction prevents the possibility of runtime failure with the following code: | |
animals.Push (new Camel()); // Trying to add Camel to bears |
数组
由于历史原因,数组类型支持协变。这说明如果 B 是 A 的子类,则 B [] 可以转换为 A [](A 和 B 都是引用类型)。
Bear[] bears = new Bear[3]; | |
Animal[] animals = bears; // OK |
这种复用性的缺点是元素的赋值可能在运行时发生错误:
animals[0] = new Camel(); // Runtime error |
声明协变类型参数
在接口和委托的类型参数上指定 out 修饰符可将其声明为协变参数。和数组不同。这个修饰符保证了协变类型参数完全是类型安全的。
public interface IPoppable<out T> { T Pop(); } | |
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(); |
T 上的 out 修饰符表明 T 只用于输出的位置(例如方法的返回值)。
接口中的协变或逆变都是很常见的,在接口中同时支持协变和逆变则是很少见的。
bears 到 animals 的转换是由编译器保证的,因为类型参数具有协变性。在这种情况下,若视图将 Camel 实例入栈,则编译器会阻止这种行为。由于 T 只能在输出位置出现,因此不可能将 Camel 类输入接口中。
特别注意,方法中的 out 参数是不支持协变的,这是 CLR 的限制。
不管是类型参数还是数组,协变(和逆变)仅对引用转换有效,对装箱转换无效。因此,如果编写了一个接受 IPoppable<object> 类型参数的方法,那么可以使用 IPoppable<string > 调用它,但不能是 IPoppable<int>。
# 逆变
假设 A 可以隐式引用转换为 B,如果 X<A> 允许引用类型转换为 X<B>,则类型 X 具有协变类型参数。而逆变的转换方向正好相反,即从 X<B > 转换到 X<A>。它仅在类型参数出现在输入位置上并用 in 修饰符标记时才行得通。
public interface IPushable<in T> { void Push (T obj); } | |
IPushable<Animal> animals = new Stack<Animal>(); | |
IPushable<Bear> bears = animals; // Legal | |
bears.Push (new Bear()); |
IPushable 中没有任何成员输出 T,所以将 animals 转换为 bears 时不会出现问题(但是通过这个接口无法实现 Pop 方法)。
即使 T 含有相反的可变性标记,Stack<T> 类可以同时实现 IPushable<T > 和 IPoppable<T>。由于只能通过接口而不是类实现可变性,因此在进行可变性转换之前,必须首先选定 IPoppable 或者 IPushable 接口。而选定的接口会限制操作在合适的可变性规则下执行。
这也说明了为什么类不允许接受可变性参数类型:因为具体实现通常都需要数据进行双向流动。
public interface IComparer<in T> | |
{ | |
// Returns a value indicating the relative ordering of a and b | |
int Compare (T a, T b); | |
} | |
var objectComparer = Comparer<object>.Default; | |
// objectComparer implements IComparer<object> | |
IComparer<string> stringComparer = objectComparer; | |
int result = stringComparer.Compare ("Brett", "Jemaine"); |
与协变正好相反,如果将逆变的类型参数用在输出位置(例如返回值或者可读属性)上,编译器将会报告错误。
# C# 泛型和 C++ 模板的对比
C# 的泛型和 C++ 的模板在应用程序中很相似,但是它们的工作原理却大不相同。两者都发生了生产者和消费者的关联,且生产者的占位符将被消费者填充。但是在 C# 泛型中,生产者的类型(开放类型,如 List<T>)可以编译到程序库(如 mscorlib.dll)中。这是因为生产者和消费者进行关联生成封闭类型是在运行时发生的。而 C++ 模板中,这一关联是在编译时进行的。这意味着 C++ 不能将模板库部署为 .dll,它们只存在于源代码中。这令动态语法检查难以实现,更不用说即使创建或参数化类型了。
// To dig deeper into why this is the case, consider again the Max method in C#: | |
static T Max <T> (T a, T b) where T : IComparable<T> | |
=> a.CompareTo (b) > 0 ? a : b; | |
// Why couldn’t we have implemented it like this? | |
static T Max <T> (T a, T b) | |
=> (a > b ? a : b); // Compile error |
第二种写法,Max 需要在编译时支持所有可能的 T 类型值。由于对于任意类型 T,运算符 > 没有同一的含义,因此上述程序无法通过编译。实际上,并不是所有类型都支持 > 运算符。相对地,下面的代码使用 C++ 的模板编写的 Max 方法。改代码会针对每一个 T 值分别编译,对特定 T 呈现不同的 > 语义,而当 T 不支持 > 运算符时编译失败:
template <class T> T Max (T a, T b) | |
{ | |
return a > b ? a : b; | |
} |