C#核心笔记——(三)在C#中创建类型
3.1 类
类是最常见的一种引用类型,最简单的类的声明如下:
class MyClass{}
而复杂的类可能包含如下内容:
1.在class关键字之前:类的特性(Attribute)和修饰符。非嵌套的类修饰符有:
public、internal、abstract、sealed、static、unsafe和partial。
2.紧接MyClass:泛型参数、唯一基类与多个接口。
3.在花括号内:类成员(方法、属性、索引器、事件、字段、构造器、重载运算符、嵌套类型和终结器)。
3.1.1 字段
字段(field)是类或结构体中的变量成员。例如class Octopus中的 name、Age:
class Octopus
{
string name;
public int Age = 10;
}
字段可以用以下修饰符进行修饰:
1.静态修饰符:static
2.访问权限修饰符:public internal private protected
3.继承修饰符: new
4.不安全代码修饰符:unsafe
5.只读修饰符:readonly
6.线程访问修饰符:volatile
3.1.1.1 readonly只读修饰符
readonly修饰符防止字段在构造后进行变更。只读字段只能在声明时或在其所属的类型构造器中赋值。
3.1.1.2 字段初始化
字段不一定要初始化。没有初始化的字段均为默认值(0、\o、null、false)。字段的初始化逻辑在构造器之前运行。
public int Age;//0
3.1.1.3 同时声明多个字段
为了简便,可以用逗号分隔的列表声明一组相同类型的字段。这是声明具有共同特性和修饰符的一组字段的简便写法。例如:
static readonly int legs = 8,eyes = 2;
3.2 方法
方法用一组语句实现某个行为。方法从调用者指定的参数中获得输入数据,并通过指定的输出类型将输出数据返回给调用者。方法可以返回void类型,表明它不会向调用者返回任何值,此外,方法还可以通过ref/out参数向调用者返回输出数据。
方法的签名在这个类型的签名必须是唯一的。方法的签名由它的名字和一定顺序的参数类型(但不包含参数名和返回值类型)组成。
方法可以用以下修饰符修饰:
1.静态修饰符:static
2.访问权限修饰符:public internal private protected
3.继承修饰符:new virtual abstract override sealed
4.部分方法修饰符:partial
5.非托管代码修饰符:unsafe extern
6.异步代码修饰符:async
3.1.2.1 表达式体方法
以下仅由一个表达式构成的方法:
static int Func(int x)
{
return x * 2;
}
可以用表达式体方法简洁地表示,用双箭头来取代花括号和return关键字:
static int Func(int x) => x * 2;
表达式体函数也可以用 void 作为放回类型:
static void Func(int x) => Console.WriteLine(x);
3.1.2.2 重载方法
只要确保方法签名不同,可以在类型中重载方法(多个方法公用一个名称)。例如,以下一组方法可以同时出现在同一个类型中:
void Foo(int x) { }
void Foo(double x) { }
void Foo(int x, float y) { }
void Foo(float x, int y) { }
3.1.2.3 按值传递和按引用传递
参数按值传递还是按引用传递也是方法签名的一部分。例如,Foo(int)和Foo(ref int)或Foo(out int)可以同时出现在一个类中。但Foo(ref int)和Foo(out int)不能同时出现在一个类中:
void Foo(int x) {x = 2;}
void Foo(ref int x) {x = 2;}
void Foo(out int x) {x = 2;}//Comoile-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;
}
局部方法(上述例子中的Cube方法)仅仅在包含它的方法(上例中的WriteCubes方法)范围内可见。这不仅简化了父类型还可以让阅读代码的人一眼看出Cube不会在其他地方使用。另外一个优势是局部方法可以访问父方法中的局部变量和参数,这可以导致很多后果。
局部方法还可以出现在其他的类型的函数中,例如属性访问器和构造器。你甚至可以将局部方法放在其他的局部方法中,放在使用语句块的Lambda表达式中。同时局部方法可以是迭代的和异步的。
局部方法不能用static修饰。如果父方法是静态的,那么局部方法也是隐式静态的。
3.1.3 实例构造器
构造器执行类或结构体的初始化代码。构造器的定义和方法的定义类似,区别仅在于构造器名和返回值只能和封装它的类型相同:
public class Panda
{
string name;
public Panda(string n) //Define constructor
{
name = n;
}
}
实例构造器支持以下的修饰符:
1.访问权限修饰符:public internal private protected
2.非托管代码修饰符:unsafe extern
从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)
{
this.price = price;
}
public Wine(decimal price, int year) : this(price)
{
Year = year;
}
}
当构造器调用另一个构造器时,被调用的构造器先执行。
还可以向另一个构造器传递表达式:
public Wine(decimal price,DateTime year): this(price,year.Year)
{
}
表达式内不能使用this引用,例如,不能调用实例方法(这是强制性的。由于这个对象当前还没有用过构造器初始化完毕,因此调用任何方法都有可能失败)。但是表达式可以调用静态方法。
3.1.3.2 隐式无参数构造器
C#编译器会自动为没有显式定义的构造器的类生成无参数公有构造器。但是,一旦显式定义少了一个构造器,系统就不再自动生成无参数的构造器。
3.1.3.3 构造器和字段的初始化顺序
之前提到,字段可以再声明时初始化为其默认值:
class Player
{
int Id = 11235;
int health = 100;
}
字段的初始化按声明先后顺序,再构造器之前执行。
3.1.3.4 非公有构造器
构造器不一定都是公有的。通常,定义非公有的构造器的原因是为了通过一个静态方法调用来控制实例的创建。静态方法可以从一个池中返回对象,而不必每次创建一个新对象的实例。静态方法还可以根据不同的输入参数返回不同的子类对象:
public class Class1
{
Class1() { }
public static Class1 Create()
{
//自定义返回实例化对象的逻辑
}
}
3.1.3.4 解构器
C#7引入了解构器。一个解构器(或称之为解构方法)就像构造器的反过程:构造器使用若干值作为参数,并且将它们赋值给字段;而解构器则相反将字段反向赋值给若干变量。
解构器的名字必须为Deconstruct,并且拥有一个或多个out参数,例如:
public class Rectangle
{
private readonly float width, height;
public Rectangle(float width, float height)
{
this.width = width;
this.height = height;
}
public void Deconstruct(out float width, out float height)
{
width = this.width;
height = this.height;
}
}
Main方法调用,解构width:
var rect = new Rectangle(3, 4);
(float d_width,_) = rect;
Console.WriteLine(d_width);
第二行是解构调用。它创建了两个局部变量并调用Deconstruct方法。上述解构调用等价于:
var rect = new Rectangle(3, 4);
float d_width;
rect.Deconstruct(out d_width, out _);
Console.WriteLine(d_width);
或者
var rect = new Rectangle(3, 4);
rect.Deconstruct(out var d_width, out _);
Console.WriteLine(d_width);
解构器调用允许隐式类型推断,因此我们可以将其简写为:
var rect = new Rectangle(3, 4);
(var d_width, var _) = rect;
或者
var rect = new Rectangle(3, 4);
var(d_width, _) = rect;
如果解构器中的变量已经定义过了,那么可以忽略声明:
var rect = new Rectangle(3, 4);
float width, height;
(width, height) = rect;
上述操作也称为解构赋值。
我们可以通过重载Deconstruct方法向调用者提供一系列解构方法。
3.1.4 对象初始化器
为了简化对象的初始化值,可以在调用构造器之后直接通过对象初始化器设置对象的可访问字段或属性。例如下面的类:
public class Bunny
{
public string Name;
public bool LikesCarrots;
public bool LikesHumans;
public Bunny() { }
public Bunny(string n) { Name = n; }
}
就可以用对象初始化器对Bunny对象进行实例化:
Bunny b1 = new Bunny { Name = "Bo", LikesCarrots = true, LikesHumans = true };
Bunny b2 = new Bunny("Bo") { LikesHumans=true ,LikesCarrots=true};
构造b1和b2的代码等价于:
Bunny temp1 = new Bunny();
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;
使用临时变量是为了确保在初始化过程中如果抛出异常,则不会得到一个部分初始化的对象。
使用对象初始化器还是使用可选参数
如果不使用对象初始化器,还可以令Bunny的构造器接收可选参数:
public Bunny(string name,bool likesCarrots = false, bool likesHumans = false) : this(name)
{
LikesCarrots = likesCarrots;
LikesHumans = likesHumans;
}
我们还可以使用以下语句构造Bunny对象:
Bunny b1 = new Bunny(name:"Bo",likesHumans:true);
这样做的优点是我们可以将Bunny的字段(或属性)设置为只读。如果在对象的生命周期内不需要改变字段值或属性值,则将其设置为只读是非常有用的。
缺点是,所有的可选参数都需要由调用者处理。换句话说,C#会将我们的构造器调用翻译为:
Bunny b = new Bunny("Bo", false, true);
如果另一个程序集实例化Bunny,则当Bunny类再次添加一个可选参数(如licksCats)的时候就会出错。除非引用该类的程序集也重新编译,否则它还将继续调用三个参数的构造器(现在已经不存在了)而造成运行时错误。(还有一种难以发现的错误是,如果我们修改了某个可选参数的默认值,则另一个程序集的调用者在重新编译之前,还会继续使用旧的可选值。)
因此,如果希望程序在不同版本的程序集中保持二进制兼容,最好避免在公有方法中使用可选参数。
3.1.5 this引用
this 指代实例本身。以下例子中,Marry 方法将 partner 的 mate 字段设定为 this:
public class Person
{
public Person Mate;
public void Marry(Person partener)
{
Mate = partener;
partener.Mate = this;
}
}
this 引用可避免字段、局部变量或属性之间发生混淆。例如:
public class Test
{
string name;
public Test(string name)
{
this.name = name;
}
}
this 引用仅在类或结构体的非静态成员中有效。
3.1.6 属性
从外表看来,属性(Property)和字段很类似,但是属性内部像方法一样含有逻辑。例如,从以下代码不能判断 CurrentPrice 到底是字段还是属性。
public class Stock
{
decimal currentPrice;
public decimal CurrentPrice
{
get { return currentPrice; }
set { currentPrice = value; }
}
}
get和set属性的访问器。读属性时会运行get访问器,它必须返回属性类型的值。给属性赋值时运行set访问器。它有一个名为Value的隐含参数,其类型和属性的类型相同。它的值一般来说会赋值给一个私有字段。
尽管访问属性和字段的方式是相同的,但不同之处在于,属性在获取和设置值的时候给实现者提供完全的控制能力。这种控制能力使实现者可以选择任意内部表示形式,而无须将属性内部细节暴露给用户。本例中,set方法可以在value超出有效范围时抛出异常。
众例子中广泛使用公有字段以免干扰读者的注意力。但是在实际应用中,为了提供封装性可能会更倾向于使用公有属性。
属性支持以下的修饰符:
1.静态修饰符:static
2.访问权限修饰符:public internal private protected
3.继承修饰符:new virtual abstract override sealed
4.非托管代码: unsafe extern
3.1.6.1 只读属性和计算属性
如果定义了 get 访问器,属性就是只读的。如果只定义了set访问器,那么它就是只写的。一般很少使用只写属性。
通常属性会用一个专门的后台字段来存储其所代表的数据,但属性也可以从其他数据计算得来。例如
decimal currentPrice, sharesOwned;
public decimal Worth
{
get { return currentPrice * sharesOwned; }
}
3.1.6.2 表达式属性
从C#6开始,只读属性(就像之前的例子中那样的属性)可简写为表达式体属性。它使用双箭头替换了花括号、get 访问器和 return 关键字。
public decimal Worth => currentPrice * sharesOwned;
C#7进一步允许在set访问器上使用表达式体。其书写方法如下:
public decimal Worth
{
get => currentPrice * sharesOwned;
set => sharesOwned = value/currentPrice;
}
3.1.6.3 自动属性
属性最常见的方式是使用get和set访问器读写私有字段(字段与属性类型相同)。因而编译器会将自动属性声明自动转换为这种实现方式。因此我们可以定义为:
public class Stock
{
decimal currentPrice;
public decimal CurrentPrice
{
get;
set;
}
}
3.1.6.4 属性初始化器
C#6 开始支持自动属性的初始化器。其写法就像初始化字段一样:
public decimal CurrentPrice { get; set; } = 123;
上述写法将CurrentPrice的值初始化为123。拥有初始化器的属性可以为只读属性:
public int Maxinum { get; } = 999;
3.1.6.5 get和set的可访问性
get和set访问器可以有不同的访问级别。典型的用例是将public属性中的set访问器设置成internal或private的:
public class Foo
{
private decimal x;
public decimal X
{
get { return x; }
private set { x = Math.Round(value, 2); }
}
}
注意,属性本身应当声明具有较高的访问级别(本例中为public),然后在需要较低别的访问器上添加相应的访问权限修饰符。
3.1.6.6 CLR 属性的实现
C#属性访问器在内部会编译为get _XXX和set _XXX的方法:
public decimal get_CurrentPrice { ... }
public void set_CurrentPrice(decimal price) { ... }
简单的非虚属性访问器会被JIT(即时)编译器内联编译,消除了属性和字段访问间的性能差距。内联是一种优化方法。它用方法的函数体替代方法调用。
对于WinRT属性,编译器会将put_XXX作为属性命名而非set _XXX。
3.1.7 索引器
索引器为访问类或者结构体中封装的列表或字典型数据元素提供了自然的访问接口。索引器和属性很相似,但索引器通过索引值而非属性名称访问数据元素。例如string类具有索引器,可以通过int索引访问其中每一个char的值。
string s = "hello";
Console.WriteLine(s[0]);
Console.WriteLine(s[3]);
使用索引器的语法就像使用数组一样,不同之处在于索引参数可以是任意类型。
索引器和属性具有相同的修饰符,并且可以在方括号前插入?以使用null条件运:
string s = null;
Console.WriteLine(s?[0]);
3.1.7.1 索引器的实现
编写索引器首先要定义一个名为this的属性,并将参数定义放在一对方括号中。例如:
class Sentence
{
string[] words ="The quick brown fox".Split(' ');
public string this[int wordNum]
{
get { return words[wordNum]; }
set { words[wordNum] = value; }
}
}
以下展示了索引器的使用方法:
Sentence s = new Sentence();
Console.WriteLine(s[3]);
s[3] = "kangaroo";
Console.WriteLine(s[3]);
一个类型可以定义多个参数类型不同的索引器,一个索引器也可以包含多个参数:
public class Bag
{
int[] inventory = new int[] { 1000, 500, 5000 };
public int[] Inventory => inventory;
string[] brand = new string[] { "aimsh","aveil","linser" };
private Dictionary<string, int> personPurchaseNumInfo = new Dictionary<string, int>();
public string this[int index,string name]
{
get
{
if (personPurchaseNumInfo.ContainsKey(name))
{
personPurchaseNumInfo[name] += 1;
}
else
{
personPurchaseNumInfo.Add(name, 1);
}
inventory[index]--;
return brand[index];
}
}
public void PersonPurchaseInfo()
{
foreach (var item in personPurchaseNumInfo)
{
Console.WriteLine(item.Key + "购买数量:" + item.Value);
}
}
}
如果省略set访问器,则索引器就是只读的。
public string this[int wordNum] => words[wordNum];
3.1.7.2 CLR索引器的实现
索引器在内部会编译为名为get_Item和set_Item的方法,如下所示:
public string get_Item(int wordNum){...}
public string set_Item(int wordNum,string wordValue){...}
3.1.8 常量
常量是一种值永远不会改变的静态字段。常量会在编译时静态赋值,编译器会在常量使用点上直接替换该值。常量可以是内置的数据类型:bool、char、string或者枚举类型。
常量用关键字const声明,并且必须用初始化。例如:
public class Test
{
public const string Message = "Hello world";
}
常量在使用时比器static readonly字段有着更多的限制,不仅能够使用的类型有限,而且初始化字段的语句含义也不同。其他的不同之处还有常量是在编译时进行赋值的。例如:
public static double Circumference(double radius)
{
return Math.PI * radius;
}
这样做是合理的。因为PI是常量,它的值永远不变。相反,static readonly字段可以在每一个应用程序中有不同的值。
static readonly字段的好处还在于当提供给其他程序集时,可以在后续版本中更新其数值。例如,假设程序集X提供了一个如下的常量:
public const decimal ProgramVersion = 2.3M;
如果程序集Y引用了程序集X并使用了这个常量,那么值2.3将在编译时固定在程序集Y中。这意味着如果X后来重新编译将其值更改为2.4,那么Y仍将使用旧值2.3直至Y重新编译。而static readonly则不存在这个问题。
常量也可在方法内声明,例如:
static void Main(string[] args)
{
const double twoPI = 2 * Math.PI;
}
非局部变量可以使用以下的修饰符:
1.访问权限修饰符:public internal private protected
2.继承修饰符:new
3.1.9 静态构造器
每个类型的静态构造器只会执行一次,而不是每个实例执行一次。一个类型只能定义一个静态构造器,名称必须和类型同名,且没有参数:
class Test1
{
static Test1()
{
Console.WriteLine("Type Initialized");
}
}
运行时将在类型使用之前调用静态构造器,以下两种行为可以触发静态构造器执行:
1.实例化类型
2.访问类型的静态变量
静态构造器只支持两个修饰符:unsafe 和 extern。
如果静态构造器抛出了未处理的异常,则类型在整个应用程序生命周期内都是不可用的。
静态构造器和字段初始化顺序
静态字段初始化器会在调用静态构造器前运行。如果类型没有静态构造器,字段会在类型被使用之前或者在运行时种更早的时间进行初始化。
静态字段初始化器按照字段声名的先后顺序运行。下面例子中X初始化为0而Y初始化为3.
public class Func
{
public static int X = 0;//0
public static int Y = 3;//3
}
如果我们交换两个字段初始化顺序,两个字段都将初始化为3。以下示例会先打印0后打印3,因为字段初始化器在X初始化为3之前创建了Foo的实例:
class Program
{
static void Main()
{
Console.WriteLine(Func.X);
}
}
public class Func
{
public static Func Instance = new Func();
public static int X = 3;//3
public Func()
{
Console.WriteLine(X);//0
}
}
如果把X放在Instance的上面,则两个字段上下两次都输出3。
3.1.10 静态类
类可标记为static,表明它必须只能够由static成员组成,并不能够派生子类。System.Console和System.Math类就是静态类的最好实例。
3.1.11 终结器
终结器是只能够在类中使用的方法。该方法在垃圾回收器回收未引用的对象占用的内层前调用。终结器的语法是类型的名称加上 ~ 前缀。
class Classf
{
~Classf()
{
}
}
事实上,这是C#语言重写Object类的finalize方法的语法。编译器会将其扩展为如下声明:
protected override void Finalize()
{
base.Finalize();
}
终结器允许使用以下修饰符:
非托管代码修饰符:unsafe
从C#7开始,仅仅包含一个语句的终结器可以写为表达式体语法:
~Classf() => Console.WriteLine("Finalize");
3.1.12 分部类型和方法
分部类型允许一个类型分开进行定义,典型的做法是分开在多个文件中。部分类使用场景是从其他源文件自动生成分别类(例如从Visual Studio 模板或设计器),而这些类仍然需要额外手动编写方法。例如:
// PaymentFormGen.cs - auto -generated
partial class PaymentForm { }
// PaymentForm.cs - hand -authored
partial class PaymentForm { }
每一个部分必须包含partail声明。因此以下的写法是不合法的
分部类型的各个组成部分不能包含冲突的成员,例如具有相同参数的构造器。分部类型完全由编译器处理,因此各部分在编译时必须可用,并且必须编译在同一个程序集中。
可以在多个分部类声明中指定基类,只要基类是同一个基类即可。此外,每一个分部类型组成部分可以独立指定实现的接口。
编译器并不保证分部类型声明中的各个组成部分之间的字段初始化顺序。
分部方法
分部类型可以包含分部方法。这些方法能够令字段生成的分部类型为手动编写的代码提供自定义钩子(hook)。例如:
partial class PaymentForm //In auto-generated file
{
partial void ValidatePayment(decimal amount);
}
partial class PaymentForm //In hand-generated file
{
partial void ValidatePayment(decimal amount)
{
if(amount > 1000)
{
Console.WriteLine(amount);
}
}
}
分部方法由两部分组成:定义和实现。定义一般由代码生成器生成,而实现一般由手动编写。如果没有提供方法的实现,分部方法的定义会被编译器清除(调用它的代码部分也一样)。这样,自动生成的代码既可以提供钩子又不必担心代码过于臃肿。分部方法返回值类型必须是void,且默认是private的。
3.1.13 nameof运算符
nameof运算符返回任意符号的字符串名称(类型、成员、变量等)
int count = 3;
string name = nameof(count);
Console.WriteLine(name);//count
相对于直接指定一个字符串,这样做的优点体现在静态类型检查中,可以令像Visual Studio 这样的工具明白你引用的符号。当符号重命名时,所有引用之处都会随之命名。
当指定一个类型的成员(例如属性和字段)名称时,请务必引用其类型名称。这对静态和实例成员都有效:
string name = nameof(System.Text.StringBuilder.Length);
Console.WriteLine(name);//Length
3.2 继承
类可以通过继承另一个类来对自身进行扩展或定制。继承类可以重用其所有功能而无须重新构建。类只能继承自唯一的类,但是可以被多个类继承,从而形成了类的层次。本例中,我们定义一个名为Asset的类:
public class Asset
{
public string Name;
}
接下来我们定义两个类Stock和House。它们都继承了Asset类。它们具有Asset类的所有特征,而各自又有自身新增的成员定义:
public class Stock : Asset
{
public long SharesOwned;
}
public class House : Asset
{
public decimal Mortgate;
}
下面是两个类的使用方法:
Stock msft = new Stock { Name = "MSFT", SharesOwned = 1000 };
Console.WriteLine(msft.Name);
Console.WriteLine(msft.SharesOwned);
House mansion = new House { Name = "Mansion",Mortgate= 250000 };
Console.WriteLine(mansion.Name);
Console.WriteLine(mansion.Mortgate);
派生类Sotck和House都从基类继承了Name属性。
派生类也称为子类(subclass);基类也称为超类(superclass)。
3.3 多态
引用是多态的。意味着x类型的变量可以指向x子类的对象。例如,考虑如下的方法:
public static void Display(Asset asset)
{
Console.WriteLine(asset.Name);
}
这个方法可以用来显示Stock和House的实例,因为这两个类都继承自Asset:
Stock msft = new Stock { Name = "MSFT", SharesOwned = 1000 };
House mansion = new House { Name = "Mansion",Mortgate= 250000 };
Display(msft);
Display(mansion);
多态之所以能够实现,是因为子类(Stock和House)具有基类(Asset)的全部特征,反过来则不正确。如果Display转而接受House类,则不能够把Asset传递给它。
public static void Display(House house)
{
Console.WriteLine(house.Mortgate);
}
Stock msft = new Stock { Name = "MSFT", SharesOwned = 1000 };
House mansion = new House { Name = "Mansion",Mortgate= 250000 };
Display(new Asset());
引起编译时错误
3.2.2 类型转换和引用转换
对象引用可以:
1.隐式向上转换为基类的引用
2.显式向下转换为子类的引用
各个兼容的类型的引用之间向上或者向下类型转换仅执行引用转换:(逻辑上)生成一个新的引用指向同一个对象。向上转换总是能够成功,而向下转换只有在对象的类型符合要求时才能成功。
3.2.2.1 向上类型转换
向上类型转换创建一个基类指向子类的引用。例如:
Stock stock = new Stock();
Asset a = stock;
向上转换之后,变量a仍然时msft指向Stock对象。被引用的对象本身不会被替换或者改变:
Stock stock = new Stock();
Asset a = stock;
Console.WriteLine(a == stock);//True
Console.WriteLine(a.GetType() == typeof(Stock));//True
Console.WriteLine(a.GetType() == typeof(Asset));//False
虽然a与stock均引用同一对象,但a在该对象上的视图更严格:
Console.WriteLine(a.Name);//OK
Console.WriteLine(a.SharesOwned);//Error: SharesOwned undefined
上例的最后一行会产生一个编译时错误,这是因为变量a是Asset类型,即使引用了Stock类型的对象。如果要访问SharesOwned字段,必须将Asset类型向下转换为Stock类型。
3.2.2.2 向下类型转换
向下转换是从基类引用创建一个子类引用。例如:
Stock stock = new Stock();
Asset a = stock;
Stock s = (Stock)a;
Console.WriteLine(s.SharesOwned);
Console.WriteLine(s == a);//True
Console.WriteLine(s == stock);//True
向上引用仅仅影响引用,而不会影响被引用的对象。而向下转换必须是显式转换,因为它有可能导致运行时错误:
House house = new House();
Asset a = house;//Upcast always succeeds
Stock s = (Stock)a;//Downcast fails: a is not a Stock
如果向下转换失败,会抛出System.InvalidCastException。这是一个运行时类型检查的例子。
3.2.2.3 as运算符
as 运算符在向下类型转换出错时返回null(而不是抛出异常):
Asset a = new Asset();
Stock s = a as Stock;
这个操作相当有用,接下来只需要判断结果是否为null。
if(s != null)
{
Console.WriteLine(s.SharesOwned);
}
如果a不是Srock类型,则第一行代码会抛出System.InvalidCastException,很清楚地描述了错误。而第二行会抛出System.NullReferenceException,这就比较模糊。因为不容易区分a不是Stock类型和a是null这两种不同的情况。
从另一个角度看,使用类型转换运算符即告诉编译器:“我确定这个值的类型,如果判断错误,那说明代码有缺陷,请抛出一个异常!”而如果使用as运算符,则表示不确定其类型。需要根据运行时结果来确定执行的分支。
as运算符不能用来实现自定义转换,也不能用于数值的转换:
int x = 3;
long y = x as long;
as和类型转换运算符也可以用来实现向上类型转换,但是不常用。因为隐式转换就已经足够了。
3.2.2.4 is运算符
is运算符检查引用的转换是否能够成功,即对象是否从某个特定的类派生(或是实现某个接口)。该运算符常在向下类型转换前使用:
Stock s = new Stock();
s.SharesOwned = 1024;
Asset a = s;
if(a is Stock)
Console.WriteLine(((Stock)a).SharesOwned);
如果拆箱转换能成功执行,则is运算符也会返回true,但它不能用于自定义类型转换和数值转换。
3.2.2.5 is运算符和模式变量
从C#7开始我们可以使用is运算符的同时引入一个变量。
Stock s1 = new Stock();
s1.SharesOwned = 1024;
Asset a = s1;
if(a is Stock s2)
Console.WriteLine(s2.SharesOwned);
上述代码等价于
Stock s1 = new Stock();
s1.SharesOwned = 1024;
Asset a = s1;
Stock s2;
if (a is Stock)
{
s2 = (Stock)a;
Console.WriteLine(s2.SharesOwned);
}
引入的变量仅可以“立即使用”,因此以下代码是合法的:
Stock s1 = new Stock();
s1.SharesOwned = 1024;
Asset a = s1;
if (a is Stock s2 && s2.SharesOwned > 1000)
Console.WriteLine("Wealthy");
同时引入变量即使在is表达式之外仍然在作用域内。例如:
Stock s1 = new Stock();
s1.SharesOwned = 999;
Asset a = s1;
if (a is Stock s2 && s2.SharesOwned > 1000)
Console.WriteLine("Wealthy");
else
s2 = new Stock();
Console.WriteLine(s2.SharesOwned);
3.2.3 虚函数成员
提供特定实现的子类可以重写(override)标识为virtual的函数。方法、属性、索引器和事件都可以声明为virtual:
public class Asset
{
public string Name;
public virtual decimal Liability => 0;
}
public class Stock : Asset
{
public long SharesOwned;
}
public class House : Asset
{
public decimal Mortgate;
public override decimal Liability => Mortgate;
}
默认的情况下,Asset类型的Liability属性为0,Stock类不用限定这以行为,而House类则令Liability属性放回Mortage的值。
House manson = new House { Name = "McMansin", Mortgate = 250000 };
Asset a = manson;
Console.WriteLine(manson.Liability);//250000
Console.WriteLine(a.Liability); //250000
虚方法和重写的方法的签名、返回值以及可访问性必须完全一致。重写方法可以通过base关键字调用其基类的实现。
从构造器调用虚方法有潜在的危险性,因为编写子类的人在重写方法的时候未必知道现在正在操作一个未完全实例化的对象。换言之,重写的方法很可能最终会访问到以下方法或属性,而这些方法或属性依赖的字段还未被构造器初始化。
3.2.5 抽象类和抽象成员
声明为抽象的类不能够实例化,只有抽象类的具体实现子类才能实例化。
抽象类中可以定义抽象成员,抽象成员和虚成员相似,只不过抽象成员不提供默认的实现。除非子类也声明为抽象类,否则其实现必须由子类提供:
抽象类:
public abstract class Animal
{
public abstract string Name { get; }
public abstract void Eat();
}
子类:
public class Monkey : Animal
{
string name;
public override string Name => name;
public Monkey(string name)
{
this.name = name;
}
public override void Eat()
{
Console.WriteLine("喜欢吃水果");
}
}
public class Tiger : Animal
{
string name;
public override string Name => name;
public Tiger(string name)
{
this.name = name;
}
public override void Eat()
{
Console.WriteLine("喜欢吃肉");
}
}
调用:
Monkey monkey = new Monkey("猴欢欢");
Console.WriteLine("我叫" + monkey.Name);
monkey.Eat();
Tiger tiger = new Tiger("虎跳跳");
Console.WriteLine("我叫" + tiger.Name);
tiger.Eat();
3.2.6 隐藏继承成员
基类和子类可能定义相同的成员
public class A
{
public int counter = 1;
}
public class B : A
{
public int counter = 2;
}
类B中的Counter字段隐藏了类A中的Counter字段。通常,这种情况是在定义了子类成员之后又意外地将其添加到了基类中而造成的。因此,编译器会产生一个警告并采用下面的方法避免这种二义性:
1.A的引用(在编译时)绑定到A.Counter
2.B的引用(在编译时)绑定到B.Counter
有时需要故意隐藏一个成员。此时可以在子类的成员上中使用new修饰符。new修饰符仅用于阻止编译器发出警告,写法如下:
public class A
{
public int counter = 1;
}
public class B : A
{
public new int counter = 2;
}
new修饰符可以明确将你的意图告知编译器和其他开发者:重复的成员是有意义的。
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");
}
}
以下代码展示了Override和Hider的不同行为:
Overrider over = new Overrider();
BaseClass b1 = over;
over.Foo();//Overrider.Foo
b1.Foo();//Overrider.Foo
Hider hider = new Hider();
BaseClass b2 = hider;
hider.Foo();//Hider.Foo
b2.Foo();//BaseClass.Foo
3.2.7 密封类和函数
重写的函数成员可以使用sealed关键字密封其实现,防止其他的子类再次重写。在前面的虚函数成员示例中,我们可以密封House类的Liability实现,来防止继承了House的子类重写Liability:
public class House : Asset
{
public decimal Mortgate;
public sealed override decimal Liability => Mortgate;
}
子类无法重写
也可以在类中使用sealed修饰符类密封整个类,这会隐式地密封类中所有的虚函数。密封类比密封函数成员更常见。
虽然密封了可以防止重写,但是它却无法阻止成员被隐藏。
public class WhiteHouse : House
{
public new decimal Liability => Mortgate;
}
3.2.8 base关键字
base关键字和this关键字很相似。它有两个重要目的:
1.从子类访问重写的基类函数成员
2.调用基类的构造器
本例中,House类用关键字base访问Asset类对Liability的实现:
public class House : Asset
{
public decimal Mortgate;
public override decimal Liability => base.Liability + Mortgate;
}
通过base关键字,我们用非虚的方式访问Asset的Liability属性。这意味着不管实例的运行时类型如何,都将访问Asset类的相应属性。
如果Liability是隐藏属性而非重写的属性,该方法也同样有效。(也可以在调用相应函数前,将其转换为基类来访问隐藏的成员)
3.2.9 构造器和继承
子类必须声明自己的构造器。派生类可以访问基类的构造器,但是并非自动继承。例如,如果我们定义了如下的Baseclass和Subclass:
public class Baseclass
{
public int X;
public Baseclass() { }
public Baseclass(int x) { this.X = x; }
}
public class Subclass : BaseClass { }
则下面的语句是非法的:
Subclass subclass = new Subclass(123);
Subclas必须重新定义它希望对外公开的任何构造器。不过,它可以使用base关键字调用基类的任何一个构造器:
public class Subclass : Baseclass
{
public Subclass(int x) : base(x) { }
}
base关键字和this关键字很像,但base关键字调用的是基类的构造器。
基类的构造器总是先执行,这保证了基类的初始化发生在子类特定的初始化之前。
3.2.9.1 隐式调用基类的无参数构造器
如果子类的构造器省略base关键字,那么基类的无参数构造器将被隐式调用:
public class Baseclass
{
public int X;
public Baseclass() { X = 1; }
}
public class Subclass : Baseclass
{
public Subclass()
{
Console.WriteLine(X);
}
}
调用
Subclass subclass = new Subclass();
如果基类没有可访问的无参数的构造器,子类的构造器中就必须使用base关键字。
3.2.9.2 构造器和字段初始化顺序
1.从子类到基类A)初始化字段B)计算被调用的基类构造器中的参数
2.从基类到子类A)构造器方法体的执行
例如:
public class B
{
public int x = 1;//Executes 3rd
public B(int x)
{
Console.WriteLine("B construct " + x);//Executes 4rd
}
}
public class C : B
{
public int y = 1;//Executes 1rd
public C(int x)
: base(x + 1)//Executes 2rd
{
Console.WriteLine("C construct " + x);//Executes 5rd
}
}
3.2.10 重载和解析
继承对方法的重载有着特殊的影响。请考虑以下两个重载:
static void Func(Asset a)
{
Console.WriteLine("Asset");
}
static void Func(House h)
{
Console.WriteLine("House");
}
当重载被调用时,类型最明确的优先匹配:
House h = new House();
Func(h);//House
具体调用那个重载是在编译器静态决定的而非运行时决定。下面的代码调用Foo(Asset),尽管a在运行时是House类型的:
Asset a = new House();
Func(a);//Asset
如果把Asset类型转换为dynamic,则会在运行时决定调用哪个重载。这样就会基于对象的实际类型进行选择:
Asset a = new House();
Func((dynamic)a);//House
3.3 object类型
object类型(System.Object)是所有类型的最终基类。任何类型都可以向上转换为object类型。
为了说明这个类型的重要性。首先介绍通用栈。栈是一种遵循LIFO(Last-In-First-Out,先进后出)的数据结构。栈有两种操作:将对象压入(push)栈,以及将对象从栈中弹出(pop)。以下是一个可以容纳10个对象的栈的简单实现:
public class Stack
{
int position;
object[] data = new object[10];
public void Push(object obj)
{
data[position++] = obj;
}
public object Pop()
{
return data[--position];
}
}
由于Stack类操作的对象是object,所以可以实现Push或Pop任意类型的实例的操作。
Stack stack = new Stack();
stack.Push("sausage");
string s = (string)stack.Pop();
Console.WriteLine(s);
object是引用类型,承载了类的优点。尽管如此,int等值类型也可以和object类型相互转换并加入栈中。C#这种特性称为类型一致化。以下是一个例子:
stack.Push(3);
int three = (int)stack.Pop();
Console.WriteLine(three);
当值类型和object类型相互转换时,公共运行时(CLR)必须进行一些特定的工作来对接值类型和引用类型在语义上的差异。这个过程称为装箱(boxing)和拆箱(unboxing)。
3.3.1 装箱和拆箱
装箱是将值类型实例转换为引用类型实例的行为。引用类型可以说object类或接口(注:System.ValueType或Symstem.Enum是引用类型(这里区别于 enum修饰符,修饰的是值类型))。
关键字(Keyword)指的是具有特殊含义的单词,通常表示一些语法结构、数据类型、操作符号等。例如:public、class、static、void等。这些关键字都有着严格的语法规则和用法,不能被作为标识符来命名变量、方法等。
修饰符(Modifier)指的是可以用于修改类、变量、方法、接口、枚举等元素定义的关键字,用于改变其属于某种特定类型或者属性。常见的修饰符包括:
访问控制修饰符:包括public、protected、private,分别用于控制访问权限;
静态修饰符:static用于修饰静态成员变量和静态方法;
最终修饰符:final用于限制变量或方法只能赋值或调用一次;
抽象修饰符:abstract用于修饰抽象类和抽象方法;
权限修饰符:synchronized、volatile等
需要注意的是,修饰符也有一些使用限制和规则,比如一个成员变量不能同时使用final和volatile修饰符等。
例:我们将int类型转换成一个object对象。
int x = 9;
object obj = x;
拆箱操作刚好相反,它把object类型转换成原始值类型:
int y = (int)obj;
拆箱需要显式转换,、。运行时将检查提供的值类型和真正的对象类型是否匹配。并在检查出错的时候抛出InvalidCastException.例如下面的例子将抛出异常,因为long类型和int类型不匹配。
object o1 = 9;
long l = (long)o1; //InvalidCastException
下面的语句是正确的
object o1 = 9;
long l = (int)o1;
下面的语句也是正确的:
object o2 = 3.5;
int i = (int)(double)o2;
在上一个例子中,(double)是拆箱操作而(int)是数值转换操作。
装箱转换对系统提供一致性的数据类型至关重要。但这个体系并不是完美的:数组和泛型的变量只能支持引用转换,不能支持装箱转换:
object[] a1 = new string[3];//Legal
object[] a2 = new int[3];//Error
装箱拆箱中的复制语义
装箱是把值类型的实例复制到新对象中,而拆箱是把对象的内容复制回值类型的实例中。下面的实例修改了i的值,但并不会改变它先前装箱时复制的值:
int i1 = 3;
object boxed = i1;
i1 = 6;
Console.WriteLine(boxed);//3
3.3.2 静态和运行时类型检查
C#程序在静态(编译时)和运行时(CLR)都会执行类型检查。
静态类型检查使编译器能够在程序没有运行的情况下检查程序的正确性。例如,因为编译器会强制进行静态类型检查因而以下代码会发生错误:
int x = "2";
在使用引用类型转换或者拆箱操作进行向下类型转换时,CLR会执行运行时类型检查。例如:
object y = "5";
Console.WriteLine(y.GetType());//System.String
int z = (int)y;//Runtime error,downcast fail
运行时可以进行类型检查时因为堆上的每一个对象都在内部存储了类型标识,这个标识可以通过object类的GetType方法得到。
3.3.3 GetType方法和typeof运算符
C#中的所有类型在运行时都会维护System.Type类的实例。有两个基本方法可以获得System.Type对象:
1.在类型实例上调用GetType方法
2.在类型名称上使用typeof方法
GetType在运行时计算而typeof在编译时静态计算(如果是用泛型类型参数,那么它将由即使编译器解析)。
System.Type拥有诸多属性,例如类型的名称、程序集、基类型等属性。例如:
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同时还是运行时反射模型的访问入口。
3.3.4 ToString方法
ToString方法返回类型实例的默认文本描述。所有内置类型都重写了该方法。下面是对int类型使用ToString方法的示例:
int x = 1;
string s = x.ToString();
可以用下面的方式在自定义的类中重写ToString方法:
public class Panda
{
string name;
public override string ToString() => name;
public Panda(string n)=> name= n;
}
Panda p = new Panda("Petey");
Console.WriteLine(p);
如果不重写ToSring方法,那么它就会返回类型名称。
当直接在值类型对象上调用ToString这样的object成员时,若该成员是重写的则不会发生装箱。只有进行类型转换时才会执行装箱操作。
int x = 1;
string s1 = x.ToString();//Calling on nonboxed value
object box = x;
string s2 = s1.ToString();//Calling on boxed value
3.3.5 object的成员列表
下面列出object的所有成员:
public class Object
{
//
// 摘要:
// Initializes a new instance of the System.Object class.
public Object();
//
// 摘要:
// Allows an object to try to free resources and perform other cleanup operations
// before it is reclaimed by garbage collection.
~Object();
//
// 摘要:
// Determines whether the specified object instances are considered equal.
//
// 参数:
// objA:
// The first object to compare.
//
// objB:
// The second object to compare.
//
// 返回结果:
// true if the objects are considered equal; otherwise, false. If both objA and
// objB are null, the method returns true.
public static bool Equals(Object? objA, Object? objB);
//
// 摘要:
// Determines whether the specified System.Object instances are the same instance.
//
// 参数:
// objA:
// The first object to compare.
//
// objB:
// The second object to compare.
//
// 返回结果:
// true if objA is the same instance as objB or if both are null; otherwise, false.
public static bool ReferenceEquals(Object? objA, Object? objB);
//
// 摘要:
// Determines whether the specified object is equal to the current object.
//
// 参数:
// obj:
// The object to compare with the current object.
//
// 返回结果:
// true if the specified object is equal to the current object; otherwise, false.
public virtual bool Equals(Object? obj);
//
// 摘要:
// Serves as the default hash function.
//
// 返回结果:
// A hash code for the current object.
public virtual int GetHashCode();
//
// 摘要:
// Gets the System.Type of the current instance.
//
// 返回结果:
// The exact runtime type of the current instance.
public Type GetType();
//
// 摘要:
// Returns a string that represents the current object.
//
// 返回结果:
// A string that represents the current object.
public virtual string? ToString();
//
// 摘要:
// Creates a shallow copy of the current System.Object.
//
// 返回结果:
// A shallow copy of the current System.Object.
protected Object MemberwiseClone();
}
3.4 结构体
结构体和类相似,不同之处在于:
1.结构体时值类型,类是引用类型
2.结构体不支持继承(除了隐式派生自object类型,或更精确的说,是派生自System.ValueType)。
除了以下内容,结构体可以包含类的所有成员:
1.无参数的构造器
2.字段初始化
3.终结器
4.虚成员或protected成员
当一个值类型语义是,使用结构体更加理想。数值类型就是一个很好的例子。对于数值来说,在赋值时对值进行复制而不是引用进行复制是很自然的。由于结构体是值类型,因此其实例不需要在堆上实例化,创建一个类型的多个实例就更加高效了。例如,创建一个值类型的数组只需要进行一次堆的分配。
int[] array = new int[5];
结构体的构造语义
结构体的构造语义如下:
1.结构体隐式包含一个无法重写的无参数构造器,将字段按位置为0。
2.定义结构体的构造器时,必须显式为每一个字段赋值。
(需要指出的是结构体不支持字段初始化器)以下是一个声明并调用结构体构造器的示例:
public struct Point
{
int x, y;
public int X => x;
public Point(int x, int y)
{
this.x = x;
this.y = y;
}
}
调用
Point p1 = new Point();
Point p2 = new Point(1,1);
Console.WriteLine(p1.X);//0
Console.WriteLine(p2.X);//1
以下示例包含3个错误:
public struct Point
{
int x = 1;//one
int y;
public int X => x;
public Point() { }//tow
public Point(int x)//three
{
this.x = x;
}
}
如果把struct替换为class,则上面写法都合法。
3.5 结构体访问权限修饰符
为了提高封装性,类型或类型成员可以在声明中添加以下五个访问权限修饰符来限定其他类型和其他程序集对它的访问。
1.public:完全访问权限。枚举类型成员或接口成员隐含的可访问性。
2.internal:仅可以在程序集内访问,或供友元程序集访问。这是非嵌套类型的默认可访问性。
3.private:仅可以在包含类型中访问。这是类或者结构体成员的默认可访问性。
4.protected:仅可以在包含类型或子类中访问。
5.protected internal:protected和internal可访问性的并集。默认情况下尽可能将一切规定为私有,然后每一个修饰符都会提高其访问级别。所以用protected internal修饰的成员在两个方面的访问级别都提高了。
CLR有protected和internal可访问性交集的定义。
3.5.1 示例
Class2可以从本程序集外访问,而Class1不可以:
class Class1 { }
public class Class2 { }
ClassB的字段可以被本程序集的其他类型访问,而ClassA的则不可以。
class ClassA { }
public class ClassB
{
internal int x;
}
Subclass中的函数可以调用Bar但是不能调用Foo:
class BaseClassA
{
void Foo() { Console.WriteLine("Foo"); }
protected void Bar() { }
}
class SubClassA : BaseClassA
{
public void Test1()
{
Foo();//error cannot access Foo
}
void Test2()
{
Bar();
}
}
3.5.2 友元程序集
在一些高级的场景中,添加System.Runtime.CompilerServices.InternalsBisibleTo程序集的特性就可以将internal成员提供给其它的友元程序集访问。可以用如下方法指定友元程序集:
[assembly: InternalsVisibleTo("Friend")]
如果友元程序集有强名称,必须指定其完整的160字节公钥:
[assembly: InternalsVisibleTo("StrongFriend, PublicKey=002400000480000094......"
可以使用LINQ查询的方式从强命名的程序集中提取完整的公钥值:
string key = string.Join("",Assembly.GetExecutingAssembly().GetName().GetPublicKey().Select(b=>b.ToString("x2")));
Console.WriteLine(key);
3.5.3 可访问性封顶
类型的可访问性是它内部声明成员可访问性的封顶。关于可访问性封顶,最常用的示例是internal类型的public成员。例如:
class C{public void Foo(){}}
C的可访问性是internal,它作为Foo的最高访问权限,使Foo成为internal的。而将Foo指定为public的原因一般是为了将来将C的权限改为public是重构的方便。
3.5.4 访问权限修饰符的限制
当重写基类函数时,重写函数的可访问性必须一致。例如:
public 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
3.6 接口
接口和类相似,但接口只为成员提供定义而不提供实现。接口的不同之处在于:
- 接口的成员都是隐式抽象的。相反,类可以包含抽象类的成员和有具体实现的成果。
- 一个类(或者结构体)可以实现多个接口。而一个类只能够继承一个类,结构体则完全不支持继承(只能从Systen.ValueType派生)。
接口声明和类声明很相似。但接口不提供成员的实现,这是因为它的所有成员都是隐式抽象的。这些成员将由实际接口的类或结构体实现。接口只能包含方法、属性、事件、索引器,而这些正是类中可以定义位抽象的成员类型。
以下是System.Collections命名空间下的IEnumerator接口的定义:
//
// 摘要:
// Supports a simple iteration over a non-generic collection.
public interface IEnumerator
{
//
// 摘要:
// Gets the element in the collection at the current position of the enumerator.
//
// 返回结果:
// The element in the collection at the current position of the enumerator.
object Current { get; }
//
// 摘要:
// Advances the enumerator to the next element of the collection.
//
// 返回结果:
// true if the enumerator was successfully advanced to the next element; false if
// the enumerator has passed the end of the collection.
//
// 异常:
// T:System.InvalidOperationException:
// The collection was modified after the enumerator was created.
bool MoveNext();
//
// 摘要:
// Sets the enumerator to its initial position, which is before the first element
// in the collection.
//
// 异常:
// T:System.InvalidOperationException:
// The collection was modified after the enumerator was created.
void Reset();
}
接口的成员总是隐式public的。并且不能用访问权限修饰符声明。实现接口意味着它将为所有的成员提供public实现:
internal class Countdown : System.Collections.IEnumerator
{
int count = 11;
public object Current => count;
public bool MoveNext() => count-- > 0;
public void Reset()
{
throw new NotImplementedException();
}
}
可以把对象隐式转换为它实现任意一个接口,例如:
System.Collections.IEnumerator e = new Countdown();
while(e.MoveNext())
{
Console.WriteLine(e.Current.ToString());
}
尽管CountDown是internal权限的类,通过把CountDown实例转换为IEnumerator,其内部实现IEnumerator接口的成员就可以作为public成员访问。例如,如果同程序集中的一个公有类型定义了如下的方法:
public static class Util
{
public static object GetCountDown() => new Countdown();
}
另一个程序集的调用者可以执行:
System.Collections.IEnumerator e = (System.Collections.IEnumerator)Util.GetCountDown();
e.MoveNext();
如果IEumerator定义为internal,那么上面的方法就不能使用了。
3.6.1 扩展接口
接口可以从其他接口派生,例如:
public interface IUndoable
{
void Undo();
}
public interface IRedoable : IUndoable
{
void Redo();
}
IRedoable 继承了 IUndoable 接口的所有成员。换言之,实现 IRedoable 的类型也必须实现 IUndoable 的成员。
3.6.2 显式接口实现
当实现多个接口时,有时会出现成员签名的冲突。显式实现接口成员可以解决冲突。请看下面例子:
internal interface I1
{
public void Foo();
}
internal interface I2
{
public int Foo();
}
public class Widget : I1, I2
{
public void Foo()
{
Console.WriteLine("Implementation of I1.Foo");
}
int I2.Foo()
{
Console.WriteLine("Implementation of I2.Foo");
return 1;
}
}
I1和I2都有相同签名的Foo成员。Widget显式实现了I2的Foo方法,使得同一个类中同时操作两个同名的方法。调用显式实现成员的唯一方式是将其转换为对应的接口:
Widget w = new Widget();
w.Foo();//Implementation of I1.Foo
((I1)w).Foo();//Implementation of I1.Foo
((I2)w).Foo();//Implementation of I2.Foo
另一个使用显式实现接口成员的原因是隐藏那些高度定制化的或对类的正常使用干扰很大的接口成员。例如,实现了ISerializable接口的类通常会选择隐藏Iserializable成员,除非显式转换成这个接口。
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,也不能实现通常意义的重写,但是它可以重新别实现。
3.6.4 在子类中重新实现接口
子类可以重新实现基类实现的任意一个接口成员。不管基类中该成员是否为virtrual,当通过接口调用时,重新实现都能够劫持成员的实现。它对接口成员的隐式和显式实现都有效,但后者效果更好。
下面的例子中,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
((IUndoable)r).Undo();//RichTextBox.Undo
假定RichTextBox定义不变,如果TextBox隐式实现Undo:
public class TextBox : IUndoable
{
public void Undo() => Console.WriteLine("TextBox.Undo");
}
那么我们就有了另外一种调用Undo方法,如下面的所示,他将切断整个系统:
RichTextBox r = new RichTextBox();
r.Undo();//RichTextBox.Undo
((IUndoable)r).Undo();//RichTextBox.Undo
((TextBox)r).Undo();//TextBox.Undo
可以看出,通过重新实现来劫持调用的方式仅在通过接口调用成员时有效,而从基类调用时无效。这个特性通常不尽人意,因为有二义性。因此,重新实现主要适合于重写显式实现的接口成员。
接口重新实现的替代方法
即使是显式实现的成员,接口重新实现还是容易出现问题,这是因为:
- 子类无法调用基类方法
- 定义基类时不能预测方法是否会重新实现,或无法接受重新实现后的潜在问题
重新实现是子类未知时的最不理想的方法。更好的选择是在定义基类时不允许重新使用重新实现,有两种方法可以做到:
-当隐式实现成员时,如果需要将其标记为virtual
-当显式实现成员时,如果能够预测子类可能要重写某些逻辑,则使用下面模式:
public class TextBox : IUndoable
{
void IUndoable.Undo() { }
protected virtual void Undo() => Console.WriteLine("TextBox.Undo");
}
public class RichTextBox : TextBox, IUndoable
{
protected override void Undo() => Console.WriteLine("RichTextBox.Undo");
}
如果你不希望添加任何的子类,则可以把类标记为sealed以制止接口的重新实现。
3.6.5 接口和装箱
将结构体转换为接口会引发装箱。而调用结构体的隐式实现接口成员不会引发装箱。
internal interface I
{
void Foo();
}
struct S : I
{
public void Foo()
{
Console.WriteLine("S.Undo");
}
}
S s = new S();
s.Foo(); //S.Undo no boxing
I i = s;
i.Foo();//S.Undo box occurs
使用类和使用接口的对比
指定原则:
- 当能够自然地共享实现的时候,使用类和子类
- 若实现是独立的,则为类型定义接口
观察下面的类
abstract class Animal{}
abstract class Bird : Animal { }
abstract class Insect : Animal { }
abstract class FlyingCeature : Animal { }
abstract class Carnivore : Animal { }
//Concrete
class Ostrich : Bird { }
class Eagle : Bird, FlyingCeature, Carnivore { }//Illegal
class Bee : Insect, Carnivore { }//Illegal
class Flea : Insect, FlyingCeature { }//Illegal
Eagle、Bee和Flea类是无法编译的,因为继承多个是非法的。为了解决这个问题,我们需要将其中的某些类型转换为接口。问题是转换那个类型呢?遵照原则,我们看出所有的昆虫和飞鸟类共享实现,所以Insect和Bird仍然使用类。而能飞的生物的飞是独立的机制;食肉的动物的食肉是独立机制,所以我们将FlyingCreature和Carnivore转换为接口:
interface IFlyingCreature { }
interface ICarnivore { }
在特定的语句中,Bird和Insect可以对于Windows控件和Web控件;而FlyingCreature和Carnivore对应IPrintable和IUndoable。
3.7 枚举类型
枚举类型是一种特殊的值类型,可以在枚举类型中定义一组命名的数值常量。例如:
public enum BorderSide
{
Left,
Right,
Top,
Bottom
}
使用枚举类型的方法如下:
BorderSide topSide = BorderSide.Top;
bool isTop = (topSide == BorderSide.Top);//true
每一个枚举成员都对应一个整数。在默认情况下:
- 对应的数值是int类型
- 按照枚举成员的声明顺序,自动按照0、1、2…进行常量赋值。
当然,可以指定其他的整数类型代替默认类型,例如:
public enum BorderSide : byte
{
Left,
Right,
Top,
Bottom
}
也可以显式指定每一个成员对应的值
public enum BorderSide : byte
{
Left = 1,
Right = 2,
Top = 12,
Bottom = 13
}
编译器还支持显式指定部分枚举成员。没有指定的枚举成员,在最后一个显式指定的值基础上递增。因此上例等价于:
public enum BorderSide : byte
{
Left = 1,
Right = 2,
Top = 12,
Bottom
}
3.7.1 枚举类型转换
枚举类型的实例可以与它对应的整数值相互显式转换:
int i = (int)BorderSide.Left;
BorderSide side = (BorderSide)i;
bool leftOrRight = (int)side <= 2;
也可以显式将一个枚举类型转换成另一个。假设HorizontalAlignment定义为:
public enum HorizontalAlignment
{
left = BorderSide.Left,
right = BorderSide.Right,
Center
}
则两个枚举类型的转换是通过对应的数值进行的:
HorizontalAlignment h = (HorizontalAlignment)BorderSide.Right;
//same as
HorizontalAlignment h1 = (HorizontalAlignment)(int)BorderSide.Right;
在枚举表达式中,编译器会特殊对待数值字面量0。它不需要进行显式转换:
BorderSide b = 0;
if(b== 0) { }
对0进行特殊对待的原因有两个:
- 第一个枚举成员经常作为默认值。
- 在合并枚举类型中,0表示无标志。
3.7.2 标志枚举类型
枚举类型的成员可以合并。为了避免混淆,合并枚举类型的成员要显式指定,典型的值为2的幂次。例如
[Flags]
public enum BorderSide : byte
{
Left = 1,
Right = 2,
Top = 12,
Bottom
}
可以使用位运算符合并枚举类型的值,例如:|和&,它们将作用在对应的整数值上。
BorderSide leftRight = BorderSide.Left | BorderSide.Right;
if ((leftRight & BorderSide.Left) != 0)
Console.WriteLine("Include left");//Include left
string formatted = leftRight.ToString();
Console.WriteLine(formatted);//Left, Right
BorderSide s = BorderSide.Left;
s |= BorderSide.Right;
Console.WriteLine(s == leftRight); //True
s ^= BorderSide.Right;
Console.WriteLine(s); //Left
按照惯例,当枚举类型的成员可以合并时,其枚举类型一定要应用Flags特性。如果声明了一个没有标注Flags特性的枚举类型,其成员依然可以合并,但若在该枚举实例上调用ToString方法,则会输出一个数值而非一个名字。
一般来说,合并枚举类型通常用复数名称而不用单数形式。
为了方便起见,可以将合并的成员直接放在枚举的声明内:
[Flags]
public enum BorderSide : byte
{
Left = 1,
Right = 2,
Top = 12,
Bottom,
LeftRight = Left | Right,
TopBottom = Top | Bottom,
All = LeftRight | TopBottom
}
3.7.3 枚举运算符
枚举类型可用的运算符有:
= == != < > <= >= + - ^ & | ~ += -= ++ -- sizeof
位运算符、算术运算符和比较运算符都返回对应整数值的运算结果。枚举类型和整数类型之间可用做加法,但两个枚举类型之间不能做加法。
3.7.4 类型安全问题
请看下面的枚举类型:
public enum BorderSide
{
Left,
Right,
Top,
Bottom
}
由于枚举裂隙可以和它对应的整数类型相互转换,因此枚举的真实值可能超出枚举类型成员的数值范围。例如:
BorderSide b = (BorderSide)12345;
Console.WriteLine(b);//12345
位运算符和算数运算符也会产生类似的非法值:
BorderSide b = BorderSide.Left;
b++;// No errors
Console.WriteLine(b);//Right
非法的BorderSide的枚举值可能破坏如下的程序:
static void Draw(BorderSide side)
{
if(side == BorderSide.Left) { }
else if(side == BorderSide.Right) { }
else if(side == BorderSide.Top) { }
else { }
}
针对上述问题的解决方案之一是再加上一个else子句:
else if(side == BorderSide.Top) { }
else throw new ArgumentException("Invalid BorderSide");
而另外一个解决方案是显式检查枚举值的合法性。可以使用静态方法Enum.IsDefined来执行操作:
BorderSide side = (BorderSide)12345;
Console.WriteLine(Enum.IsDefined(typeof(BorderSide),side));//False
遗憾的是,Enum.IsDefined对标志枚举类型不起作用,然而下面的方法(巧妙使用了Enum.ToString()的行为)可用再标志枚举类型合法时返回true:
static bool IsFlagDefined(Enum e)
{
decimal d;
return !decimal.TryParse(e.ToString(), out d);
}
[Flags]
public enum BorderSides
{
Left,
Right,
Top,
Bottom = 8
}
for (int i = 0; i < 16; i++)
{
BorderSides sides = (BorderSides)i;
Console.WriteLine(IsFlagDefined(sides) + " " + sides);
}
3.8 嵌套类型
嵌套类型是声明在另一个类型内部的类型。例如:
public class TopLevel
{
public class Nested { }
public enum Color { Red,Blue,Tan }
}
嵌套类型有如下的特征:
- 可以访问包含它外层类型中的私有成员,以及外层类所能访问的所有内容。
- 可以声明上使用使用访问权限修饰符,而不限于public和internal.
- 嵌套类型的默认可访问性是private而不是internal
- 从外层类以外访问嵌套类型,需要使用外层类名称进行限定(就像访问静态成员一样)。
例如,为了从TopLevel外访问Color.Red,我们必须写作:
TopLevel.Color color = TopLevel.Color.Blue;
所有类型(类、结构体、接口、委托和枚举)都可以被类或结构体嵌套。
以下示例在嵌套类型上使用protected访问权限修饰符:
public class TopLevel
{
public class Nested { }
public enum Color { Red,Blue,Tan }
}
public class SubTopLevel : TopLevel
{
static void Foo() { new TopLevel.Nested();}
}
以下示例在外层类型之外的类型中引用嵌套类型:
public class TopLevel
{
public class Nested { }
public enum Color { Red,Blue,Tan }
}
class Test2
{
TopLevel.Nested n;
}
嵌套类型在编译器中得到广泛应用,例如编译器在生成迭代器和匿名方法时就会生成包含这些结构内部状态的私有嵌套类。
如果使用嵌套类型的主要原因是为了避免命名空间中类型定义杂乱无章,那么可以考虑使用嵌套命名空间。使用嵌套类型的原因应当是利用它较强的访问控制能力,或者是因为嵌套类型的类型必须访问外层类型的私有成员。
3.9 泛型
C#有两种不同的机制类编写跨类型可复用的代码:继承和泛型。但继承的复用性来自基类,而泛型的复用性是通过带有占位符的模板类型实现的。和继承相比。泛型能够提高类型的安全性,并减少类型的转换和装箱。
3.9.1 泛型类型
泛型类型中声明的类型参数(占位符类型)需要由泛型类型的消费者(即提供类型参数的一方)填充。下面是一个存放类型T实例的泛型栈类型Stack。Stack声明了当个类型参数T。
public class Stack<T>
{
int position;
T[] data = new T[100];
public void Push(T obj) => data[position++] = obj;
public T Pop() => data[--position];
}
使用Stack的方法如下:
var stack = new Stack<int>();
stack.Push(5);
stack.Push(6);
int x = stack.Pop();
int y = stack.Pop();
Console.WriteLine(x + ":" + y);
Stack用类型int填充T,这会在运行时隐式创建一个类型:Stack。
若试图将一个字符串加入Stack中则会产生一个编译错误。Stack具有如下定义(为了防止混淆,类的名字将以#代替,替换的部分将用粗体展示):
public class ###
{
int position;
T[] data = new T[100];
public void Push(T obj) => data[position++] = obj;
public T Pop() => data[--position];
}
技术上,我们称Stack是开放类型,称Stack是封闭类型。在运行时,所有的泛型实例都是封闭的,占位符已经被类型填充。这意味着以下语句是非法的:
var stack = new Stack<T>();//Illgal : T?
只有在类或者方法的内部,T才可以定义为类型参数:
public class Stack<T>
{
int position;
T[] data = new T[100];
public void Push(T obj) => data[position++] = obj;
public T Pop() => data[--position];
public Stack<T> Clone()
{
Stack<T> stack = new Stack<T>();
return stack;
}
}
3.9.2 为什么需要泛型
泛型是为了代码能够跨类型复用而设计的。假定我们需要一个整数栈,但是没有泛型的支持。那么解决方案之一是为每一个需要的元素类型硬编码不同版本的类(例如InStack、StringStack等)。显然,这将导致大量的重复代码。另一个解决方法是写一个用object作为元素类型的栈:
public class ObjectStack
{
int position;
object[] data = new object[100];
public void Push(object obj) => data[position++] = obj;
public object Pop() => data[--position];
}
但是ObjectStack类不会像硬编码的IntStack类一样只处理整数元素。而且ObjectStack需要用到装箱和向下类型转换,而这些都不能够再编码时进行检查:
//suppose we just want store integer here:
ObjectStack stack = new ObjectStack();
stack.Push("s");
int i = (int)stack.Pop();//System.InvalidCastException
我们需要的栈既要支持各种不同类型的元素,又要有一种方法容易地将栈的元素类型限定为特定类型,以提高类型安全性,减少类型转换和装箱。泛型恰好通过参数化元素类型提供了这些功能。Stack具有ObjectStack和IntStack的全部优点。与ObjectStack的共同点是Stack只需要书写一次就可以支持各种类型;而与IntStack的共同点是Stack的元素是特定的某个类型。Stack的独特之处是T,并可以再编程时任意替换。
ObjectStack在功能上等价于Stack< object >。
3.9.3 泛型方法
泛型方法在方法的签名中声明类型参数。
使用泛型方法,许多基本算法就可以用通用方式实现了。以下是交换两个任意类型T的变量值的泛型方法:
static void Swap<T>(ref T a, ref T b)
{
T temp = a;
a = b;
b = temp;
}
Swap的使用方式如下:
int x = 5;
int y = 10;
Swap(ref x,ref y);
Console.WriteLine(x + ":" + y);
通常调用泛型方法不需要提供类型参数,因为编译器可以隐式的判断推断得出。如果有二义性,则可以用下面的方式调用泛型方法:
Swap<int>(ref x,ref y);
在泛型中,只有引入类型参数(用尖括号标出)的方法才可以归为泛型方法。泛型Stack类中的Pop方法仅仅使用了类型中已有的类型参数T,因此不属于泛型方法。
唯有方法和类可以引入类型参数。属性、索引器、事件、字段、构造器、运算符等都不能声明类型参数,虽然它们可以参与使用所在类型中已经声明的类型参数。例如,在泛型栈中我们可以写一个索引器返回一个泛型项。
public T this[int index] => data[index];
类似的,构造器可以参与使用已经存在的类型参数,但是不能引入新的类型参数:
public Stack<T>(){}//Illegal
3.9.4 声明类型参数
可以在声明类、结构体、接口、委托和方法时引入类型参数。其他的结构,如属性,虽不能引入类型参数,但可以使用类型参数。例如属性Value使用T:
public struct Nullable<T>
{
public T value { get; }
}
泛型或方法可以有多个参数,例如:
class Dictionary<Tkey, TValue> { }
可以用下面方式实例化:
Dictionary<int,string> myDic = new Dictionary<int,string>();
或者:
var myDic = new Dictionary<int,string>();
只要类型参数的数量不同,泛型类型名和泛型方法的名称就可以进行重载。例如,下面的三个类型名称不会冲突:
class A { }
class A<T> { }
class A<T1, T2> { }
习惯上,如果泛型类型和泛型方法只有一个类型参数,且参数的含义明确,那么一般将其命名为T。当使用多个类型参数时,每一个类型参数都使用T作为前缀,后面跟一个更具描述性的名称。
3.9.5 typeof和未绑定泛型类型
在运行时不存在开放的类型:开放泛型类型将汇编为程序的一部分而封闭。但运行时可能存在未绑定的泛型类型,只作为Type对象存在。C#中唯一指定未绑定泛型类型的方式是使用typeof运算符:
class A<T> { }
class A<T1, T2> { }
Type a1 = typeof(A<>);
Type a2 = typeof(A<,>);
开放泛型类型一般与反射API一起使用
也可以使用typeof运算符指定封闭的类型:
Type a3 = typeof(A<int,string>);
或一个开放类型:
class B<T>
{
void X()
{
Type t = typeof(T);
}
}
3.9.6 泛型的默认值
default 关键字可用于获取泛型类型参数的默认值。引用类型的默认值未null,而值类型的默认值是将值类型的所有字段按位设置为0的值。
static void Zap<T>(T[] array)
{
for (int i = 0; i < array.Length; i++)
{
array[i] = default(T);
}
}
3.9.7 泛型约束
默认情况下,类型参数可以由任何类型来替换。在类型参数上应用约束,可以将类型参数定义为指定的类型参数。以下列出了可用的约束:
where T : BaseClass // BaseClass constraint
where T : interface //Interface constraint
where T : class //Refernce-type constraint
class A<T> where T : struct //Value-type constraint(excludes Nullable type)
where T : new() //Parameterless constructor constraint
class A<U,T> where U : T //Naked type constraint
在下面例子中GenericClass<T,U>的T要求派生自SomeClass并且实现Interface1;要求U提供无参构造器。
class SomeClass { }
interface Interface1 { }
class GenericClass<T,U> where T : SomeClass,Interface1
where U : new()
{
}
约束可以用在方法或者类型定义这些可以定义类型参数的地方。
基类约束要求类型参数必须是子类‘接口约束要求类型参数必须实现特定的接口。这些约束要求类型参数的实例可以隐式转换为相应的类和接口。例如,假定写一个泛型Max方法,返回两个值中更大的一个。我们就可以利用框架定义的IComparable泛型接口:
public interface IComparable<T> //Simplified version of interface
{
int CompareTo(T ohter);
}
CompareTo方法在this大于ohter的时候返回正值。以此接口为约束,我们可以将Max方法写为(为了分散注意力,省略了null检查):
static T Max<T>(T a,T b) where T : IComparable<T>
{
return a.CompareTo(b) > 0 ? a : b;
}
Max方法可以接受任何实现了IComparable接口的类型参数(大部分内置类型都实现了该接口,例如int和string):
int z = Max(5, 10);
string last = Max("ant", "zoo");
Console.WriteLine(z);//10
Console.WriteLine(last);//zoo
类约束和结构体约束规定T必须是引用类型。结构体约束的一个很好的例子是System.Nullable结构体:
struct Nullable<T> where T : struct { }
无参构造器约束要求T有一个无参数构造器。如果定义了这个约束,就可以在T上调用new()了:
static void Initialize<T>(T[] array) where T : new()
{
for (int i = 0; i < array.Length; i++)
array[i] = new T();
}
裸类型约束要求一个类型参数必须从另一个类型参数中派生(或匹配)。本例中,FilteredStack方法返回了另一个Stack,返回的Stack仅包含原来类中的一部分元素,并且类型参数U是类型参数T的子类。
public class Stack<T>
{
Stack<U> FilteredStack<U>() where U : T { return new Stack<U>(); }
}
3.9.8 继承泛型类型
泛型类和非泛型类一样都可以派生子类。并且子类中仍可以令基类中类型参数保持开放,如下所示:
class Stack1<T> { }
class Stack2<T>:Stack1<T> { }
子类也可以用具体的类型来封闭参数:
class IntStack : Stack<int> { }
子类型还可以引入新的类型参数:
class List<T> { }
class KeyedList<T, key> : List<T> { }
技术上,子类型中所有类型参数都是新的:可以说子类型封闭后又重新开放了基类的类型参数。这表明子类可以为其重新打开的类型参数使用更有意义的新名称:
class List<T> { }
class KeyedList<TElement, key> : List<TElement> { }
3.9.9 自引用泛型声明
一个类型可以使用自身类型作为具体类型来封闭类型参数:
public interface IEquatable<T> { bool Equals(T obj); }
public class Ballon
{
public string Color { get; set; }
public int CC { get; set; }
public bool Equals(Ballon b)
{
if (b == null) return false;
return b.Color == Color && b.CC == b.CC;
}
}
下面的写法也是合法的:
class Foo<T> where T : IEquatable<T> { }
class Bar<T> where T : Bar<T> { }
3.9.10 静态数据
静态数据对于每一个封闭的类型来说都是唯一的:
class Bob<T> { public static int Count; }
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#的类型转换运算符可以进行多种的类型转换,包括:
- 数值转换
- 引用转换
- 装箱/拆箱转换
- 自定义转换
根据已知操作数的类型,在编译时就已经决定了类型转换的方式。但在编译时操作数的类型还并未确定,使得上述规则在泛型类型参数上会出现特殊的情形。如果导致了二义性,那么编译器会产生一个错误。
最常见的场景是在执行引用转换时:
StringBuilder SBF<T>(T arg)
{
if(arg is StringBuilder)
return (StringBuilder)arg;//will not compile
}
由于不知道T的确切类型,编译器会怀疑你是否希望执行自定义转换,上述问题最简单的解决方案就是改用 as 运算符,因为它不能进行自定义类型转换,因此是没有二义性的L:
StringBuilder SBF<T>(T arg)
{
StringBuilder sb = arg as StringBuilder;
if(sb != null ) return sb;
return new StringBuilder();
}
而更一般的做法是先将其转换为object类型。这种方法行得通,因为从object转换,或将对象转换为object都不是自定义转换,而是引用还或者装箱/拆箱转换。下例中,StringBuilder是引用类型,所以一定是引用转换:
StringBuilder SBF<T>(T arg)
{
if (arg is StringBuilder)
return (StringBuilder)(object)arg;
return new StringBuilder();
}
拆箱转换也可能导致二义性。例如,下面代码可能是拆箱转换,数值转换或者自定义转换:
int Food<T>(T x) => (int)x;//Compile-time error
而解决方案也是先将其转换为object然后再将其转换为int(很明显,这是一个非二义性转换):
int Food<T>(T x) => (int)(object)x;
3.9.12 协变
假定A可以转换为B,如果X< A >可以转换为X< B >那么称X有一个协变类型参数。
由于C#有协变(convariance)和逆变(contravarience)的概念,所以“可转换”意味着可以通过隐式引用转换进行类型转换,例如,A是B的子类或者A实现B。而数值转换、装箱转换和自定义转换是不包含在内的。
例如IFoo< T >类型如果能够满足以下条件,则IFoo< T >拥有协办参数T:
IFoo<string> s = ...;
IFoo<object> b = s;
从C#4.0开始,泛型接口就支持协变类型参数了(泛型委托也支持协变类型参数)但是泛型类是不支持的。数组也支持协变(如果A有一个隐式引用转换为B,则A[]可以转换为B[])。接下来将对此进行一些讨论和比较。
协变和逆变(或简称可变性)都是高级概念。在C#中引入和强化协变的动机在于允许泛型接口和泛型类型(尤其是框架定义的那些类型,例如IEnumerable< T >)像人们期待的那样工作。即使你不了解它们背后的细节,也可以从中获益。
3.9.12.1 可变性不是自动的
为了保证静态的安全性,泛型类型参数不是自动协变的。请看下面的例子:
class Animal { }
class Bear : Animal { }
class Camel : Animal { }
public class AStack<T>
{
int positon;
T[]data = new T[10];
public void Push(T obj) => data[positon++] = obj;
public T Pop() => data[--positon];
}
接下来的语句是不能通过编译的:
AStack<Bear> bears = new AStack<Bear>();
Stack<Animal> animals = bears;//Compile-time error
这种约束避免了以下代码可能产生的运行时错误:
Stack<Animal> animals = new Stack<Animal>();
animals.Push(new Camel());
但是协变的缺失可能妨碍复用性。例如下例中,我们希望写一个Wash方法操作整个Animal栈:
class ZooCleaner
{
public static void Wash<T>(Stack<T> animals) where T : Animal { }
}
这样我们就可以使用如下方式调用Wash了:
Stack<Bear> bears = new Stack<Bear>();
ZooCleaner.Wash(bears);
另一种解决方案是让Stack< T >实现一个拥有协变类型参数的泛型接口。
3.9.12.2 数组
由于历史原因,数组类型支持协变。这说明如果B是A的子类,则B[]可以转换为A[](A和B都是引用类型)。例如:
Bear[] bears = new Bear[3];
Animal[] animals = bears;//OK
这种复用性的缺点是元素的赋值可能在运行时发生错误:
Bear[] bears = new Bear[3];
Animal[] animals = bears;//OK
animals[0] = new Camel();//Runtime error
3.9.12.3 声明协变类型参数
C# 4.0中,在接口和委托的类型参数上指定out修饰符可将其声明为协变参数。和数组不同,这个修饰符保证了协变类型参数是完全类型安全的。
为了阐述这一点,我们假定Srack< T >类实现如下的接口:
public interface IPoppable<out T>
{
T Pop();
}
T上的out修饰符表明了T只用于输出的位置。out 修饰符将类型参数标记为协变参数,并且可以进行如下的操作:
public class Stack<T> : IPoppable<T>
{
Stack<U> FilteredStack<U>() where U : T { return new Stack<U>(); }
int position;
T[] data = new T[100];
public void Push(T obj) => data[position++] = obj;
public T Pop() => data[--position];
public T this[int index] => data[index];
public Stack<T> Clone()
{
Stack<T> stack = new Stack<T>();
return stack;
}
}
var bears = new Stack<Bear>();
bears.Push(new Bear());
IPoppable<Animal> animals = bears;
Animal a = animals.Pop();
bear到animal的转换是由编译器保证的,因为类型参数具有协变性。在这种情况下,若试图将Camel实例入栈,则编译器会阻止这种行为。因为T只能在输出位置出现,因此不可能将Camel类输入接口中。
接口中的协变或逆变都是常见的,在接口中同时支持协变和逆变是很少见的。
特别注意,方法中的out参数是不支持协变的,这是CLR的限制。
如前所述,我们可以利用类型转换的协变性解决复杂性问题:
class ZooCleaner
{
public static void Wash(IPoppable<Animal> animals) { }
}
IEnumerator< T >和IEnumerable< T >接口的T都是协变的。这意味着需要时可以将IEnumerable< string > 转换为 IEnumerable< object >。
如果在输入位置,例如方法的参数或可写属性,使用协变参数则会发生编译时错误。
不管是类型参数还是数组,协变(和逆变)仅仅对引用转换有效,而对装箱转换无效。因此,如果书写了一个接受IPoppable< object > 类型参数的方法,你可以使用IPoppable< string >调用它,但不能是IPoppable < int >。
3.9.13 逆变
通过前面的介绍我们已经知道,假设A可以隐式引用转换为B,如果X< A >允许引用类型转换为X< B >,则类型X具有协变类型参数。而逆变的转换方向正好相反,即从X< B >转换到X< A >。它仅在类型参数出现在输入位置上,并用in修饰符标记才行得通。以下扩展了之前的例子,如果Stack< T >实现了如下的接口:
public class Stack<T> : IPushable<T>{}
public interface IPushable<in T>
{
void Push(T objec);
}
则以下语句是合法的:
IPushable<Animal> animals = new Stack<Animal>();
IPushable<Bear> bears = animals;
bears.Push(new Bear());
IPushable中没有任何成员输出T,所以不会出现将animals转换为bears的问题(但是通过这个接口无法实现Pop方法)。
即使T含有相反的可变性标记,Stack< T >类可以同时实现IPushable< T >和IPoppable< T >。由于只能通过接口而不是类实现可变性,因此在进行可变性转换之前,必须首先选定 IPoppable或者IPushable接口。而选定的接口会限制操作在合适的可变性规则下执行。
这也说明了为什么类不允许接受可变性参数:因为具体实现通常都需要数据进行双向流动。
再看一个例子,以下的接口是.NET Framework中的一个接口定义:
//
// 摘要:
// Defines a method that a type implements to compare two objects.
//
// 类型参数:
// T:
// The type of objects to compare.
public interface IComparer<in T>
{
//
// 摘要:
// Compares two objects and returns a value indicating whether one is less than,
// equal to, or greater than the other.
//
// 参数:
// x:
// The first object to compare.
//
// y:
// The second object to compare.
//
// 返回结果:
// A signed integer that indicates the relative values of x and y, as shown in the
// following table.
// Value – Meaning
// Less than zero –x is less than y.
// Zero –x equals y.
// Greater than zero –x is greater than y.
int Compare(T? x, T? y);
}
该接口含有逆变参数T,因此我们可以使用IComparer< object >来比较两个字符串:
var objectComparer = Comparer<object>.Default;
//bjectComparer implement IComparer<object>
IComparer<string> stringComparer = objectComparer;
int result = stringComparer.Compare("Brett", "Jemaine");
与协变正好相反,如果将协变的类型参数用在输出位置(例如返回值或者可读属性)上,则编译器将会报告错误。
3.9.14 C#泛型和C++模板对比
C#的泛型和C++的模板在应用上很相似,但是它们工作原理却大不相同。两者都发生生产者和消费者的关联,且生产者的占位符将被消费者填充。但是C#的泛型中,上传者的类型(开放类型如List< T >)可以编译到程序库中(日mscorlib.dll)。这是因为生产者和消费者进行关联生成封闭类型是在运行时发生的。而C++的模板中,这一关联是在编译时进行的。这意味着C++不能将模板库部署为.dll,它们只存在于源代码中。这令动态语法检查难以实现,更不要用说即时创建或参数化类型了。
为了深究这一情形形成的原因,我们重新观察C#的Max方法:
static T Max<T>(T a,T b) where T : IComparable<T> => a.CompareTo(b) > 0 ? a : b;
为什么我们不像下面这样做呢?
static T Max<T>(T a,T b)
{
return (a >b ? a : b); // Compile error
}
原因是,Max需要在编译时支持所有可能的T类型值。由于对任意类型T,运算符 > 没有统一的含义,因此上述程序无法通过编译。实际上,并不是所有类型都支持 > 运算符。相对的,下面的代码使用C++的模板编写的Max方法。而该代码会为每一个T值分别编译。对特定T呈现的不同的 > 语义,而当T不支持 > 运算符的时候,编译失败
template <class T> T Max<T>(T a,T b)
{
return a >b ? a : b;
}
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。 如若内容造成侵权/违法违规/事实不符,请联系我的编程经验分享网邮箱:veading@qq.com进行投诉反馈,一经查实,立即删除!