从入门到精通—笔记

前言:

这个笔记是我个人总结,主要是熟练自己查看《Visual C# 从入门到精通》(第8版)这本书时,懵然起总结的想法,只是总结一些知识点,在工作项目会用得上,但是对毫无C#语言基础的,不大适合,因为里面会结合我自己的一些看法,估计需要一些基础才能看明白,如果需要一门入门级书籍还是要去购买《Visual C# 从入门到精通》(第8版)PDF版或者纸质版去一步步跟着书籍去练习操作。

——清风一人醉

第I部分 Visual C#和Visual Studio 2015 概述

第1章 使用变量、操作符和表达式

1.1 理解语句、标识符和关键字

语句是执行操作的命令,如计算值,存储结果,或者向用户显示消息。

C#语句遵循良好定义的规则集,而这些规则描述语句的格式和构成,称为语法

描述语句做什么的规范称为语义

*划重点 C#语句语法规则:所有语句都必须以分号终止。

标识符是对程序中的各个元素(命名空间、类、方法和变量)进行标识的名称。

 

*划重点 C#选择标识符语法规则:

只能使用字母(大写和小写)、数字和下划线

标识符必须以字母或下划线开头(如result,_name)

 

C#语言保留77个标识符供使用,这些标识符称为关键字,每个关键字有特定含义。如class,namespace,using等。(其它关键字想要了解,可百度一下。)

1.2 使用变量存储信息

变量是容纳值的一个存储位置。在使用变量的时候要对它进行命名。例如 int count;

*划重点 命名变量规则:

不要以下划线开头。虽然在C#中合法,但限制了和其它语言(如Visual Basic)的代码的互操作性。

不要创建仅大小写不同的标识符。例如同时使用myVariable和MyVariable变量,它们易混淆。在Visual Basic这样不区分大小写的语言中,类的重用性也会受限。

名称以小写字母开头。

在包含多个单词的标识符中,从第二个单词起,每个单词都首字母大写(这种写法是驼峰式)例如camelCase。

1.3 使用基元数据类型

数据类型

描述

大小(位)

示例

int

整数

32

int count;

count =42;

long

整数(更大范围)

64

long wait;

wait =42L;

float

浮点数

32

float away;

away=0.42F;

double

双精度(更精确)浮点数

64

double trouble;

trouble =0.42;

decimal

货币值(具有比double更高的精度和更小的范围)

128

decimal coin;

coin =0.42M;

string

字符序列

每字符16位

string vest;

vest =“text”;

char

单字符

16

char text;

Text=‘x’;

bool

布尔值

8

bool teeth;

teeth=true;

 

注意:

L:表示long

F:表示float

M:表示decimal

1.4 使用算术操作符

加号(+)、减号(-)、星号(*)、正斜杠(/)、百分号(%)分别执行加、减、乘、除、取模。它们成为操作符运算符,对值进行“操作”或“运算”来生成新值。在C#中,乘法类操作符(*,/和%)的优先级高于加法类操作符(+和-)。

注意:

操作符并不是适合所有数据类型,可使用任何算术操作符的数据类型char,int,long,float,double或decimal。

小知识点:字符串插值:开头的$符号表明这是插值字符串,{和}之间的任何表达式都需求值并置换。没有前置的$符号,字符串{“world”}将按字面处理。

我们通常在连接字符串习惯在中间用+连接,比如:

string text=“hello”+“world”;

 

字符串插值则允许改用以下语法:

string text=$”hello{“world”}”;

 

优点:字符串插值比+操作符高效的多。由于.NET Framework处理字符串的方式,用+来连接字符串可能消耗大量内存。字符串插值还更具可读性和减少犯错机会。

(技术无绝对,根据各人喜好用什么写法都可。)

1.5 变量递增递减

递增(++)和递减(--)是一元操作符,即只有一个操作数。它们具有相同的优先级和左结合性。递增和递减操作符与众不同之处在于,它们既可以放在变量前,也可以放在变量后。在变量前使用,称为这个操作符的前缀形式;在变量之后使用,则称为这个操作符的后缀形式。例如:

count++;//后缀递增

++count;//前缀递增

count--;//后缀递减

--count;//前缀递减

count++返回递增前的count值,而++count返回递增后的count值。

1.6 声明隐式类型的局部变量

var myVariable=99;

var myOtherVariable=“hello”;

以上俩个变量myVariable和myOtherVariable称为隐式类型变量。var 关键字告诉编译器根据用于变量的初始化表达式判断变量类型。在以上例子中myVariable是int类型,而myOtherVariable是string类型。

注意:var只是在声明变量时提供一些方便。但变量一经声明,就只能编译器推断的那种类型的值赋给它。例如,不能再将float,double,string值赋给myVariable。

特别注意:只有提供表达式来初始化变量,才能使用关键字var。

以下声明会导致编译错误:

var test;//错误-编译器不能推断类型

第2章 方法和作用域

2.1创建方法

方法是一个基本的,强大的编程机制。可视为函数或者子程序相似的东西。

方法名是个有意义的标识符。

方法主体包含方法被调用时实际执行的语句。

声明一个方法的实例如下:

(对于如何声明方法简单写了一个实例,详细的内容不作解释,因为此文档提供给有基础的查看,主要是后面总结更加复杂的内容,比如方法怎么声明,这类问题百度一下,或许比我的笔记更加详细)

2.2 使用作用域

定义类作用域的实例如下:

变量myField在类内部定义,而且位于firstMethod和anotherMethod方法外部,所以具有类作用域,可由类的所有方法使用。

变量作用域范围是指该变量能起作用的程序区域。除了变量有作用域,方法也有。

第3章 使用判断语句

3.1 使用布尔操作符

布尔操作符是求值为true或false的操作符。

C#提供了几个非常有用的布尔操作符,其中最简单的是NOT(求反)操作符,它用感叹号(!)表示。!操作符求布尔值的反值。

例如:

bool areYouReady;

areYouReady=true;

!areYouReady;//则为false

3.1.1 理解相等和关系操作符

两个常用的布尔操作符是相等(==)和不等(!=)操作符。

这两个二元操作符判断一个值是否与相同类型的另一个值相等,结果是bool值。如下表:

操作符

含义

示例

结果(假定age=42)

==

等于

age==100

false

!=

不等于

age!=0

true

注意:不要混淆相等操作符(==)和赋值操作符(=)。

==和!=密切相关的是关系操作符,它们判断一个值是小于还是大于同类型的另一个值。

如下表:

操作符

含义

示例

结果(假定age=42)

<

小于

age<21

false

<=

小于或等于

age<=18

false

>

大于

age>16

true

>=

大于或等于

age>=30

true

3.1.2 理解条件逻辑操作符

C#还提供了另两个布尔操作符:逻辑AND(逻辑与)操作符(用&&表示)和逻辑OR(逻辑或)操作符(用||表示)。这两个操作符统称条件逻辑操作符,作用是将两个布尔表达式或值合并成一个布尔结果。

这两个二元操作符与相等/关系操作符相似的地方是结果也为true或false。

不同的地方是操作的值(操作数)本身必须是true或false。

例如下:

bool validPercentage;

int percent=99;

validPercentage=(percent>=0) &&(percent<=100);//返回值为true

3.1.3 短路求值

操作符&&和||都支持短路求值

有时没必要两个操作数都求值,例如,假定操作符&&的左操作数求值为false,整个表达式的结果肯定是false,无论右操作数的值是什么。

注意:如果能精心设计使用条件逻辑操作符的表达式,就可避免不必要的工作以提升代码性能。将容易计算、简单的布尔表达式放到条件逻辑操作符左边,将复杂的放到右边。许多情况下,程序并不需要对更复杂的表达式进行求值。

3.1.4 操作符的优先级和结合性总结

下表总结了迄今为止学过的所有操作符的优先级和结合性。同一类别的操作符具有相同优先级。各类别按优先级从高到低排列。

类别

操作符

描述

结合性

主要(Primary)

()

覆盖优先级

++

后递增

--

后递减

一元(Unary)

!

逻辑NOT

+

-

++

前递增

--

前递减

乘(Multiplicative)

*

/

%

求余(取模)

加(Additive)

+

-

关系(Relational)

<

小于

<=

小于或等于

>

大于

>=

大于或等于

相等(Equality)

==

等于

!=

不等于

条件AND

(Conditional AND)

&&

逻辑AND

条件OR

(Conditional OR)

||

逻辑OR

赋值(Assignment)

=

3.2 使用if语句做出判断

3.2.1 理解if语句的语法

if语句根据布尔表达式的结果选择执行两个不同的代码块

if语句的语法如下所示(ifelseC#关键字):

if(booleanExpression)

statement -1;

else

statement -2;

3.2.2 使用代码块分组语句

在前面的if语法中,if(booleanExpression)后面只有一个语句,关键字else后面也只有一个语句。但经常要在布尔表达式为true的前提下执行两个或更多语句。这时可将要运行的语句分组到新方法中,然后调用方法。但更简单的做法是将语句分组到代码块中。

代码块是用大括号封闭的一组语句。

如下例两个语句将seconds重置为0,并使minutes递增。这两个语句被放到代码块中。如果seconds的值等于59,整个代码块都会执行:

int seconds=0;

intminutes=0;

if(seconds==59)

{

seconds=0;

minutes++;

}

else

{

seconds++;

}

注意:遗漏大括号造成两个严重后果。首先,C#编译器只将第一个语句(seconds=0;)与if语句关联,下一个语句(minutes++;)不再成为if语句的一部分。其次,当编译器遇到else关键字时,不会将它与前一个if语句关联,所以会报告一个语法错误。因此,一个好习惯是用代码块定义if语句的每个分支,即使其中只包含一个语句。这样一来,以后添加代码会更省心。

(这个不好的习惯我也有犯,因为在只用写一句代码结果的时候我就偷懒没有加上大括号,要改正。。。)

3.2.3 嵌套if语句

可在一个if语句中嵌套其它if语句。这样可以链接一系列布尔表达式。它们依次测试,直至其中一个求值为true。

在下例中,假如day值为0,则第一个测试的值为true,值‘Sunday’将被赋给dayName变量。假如day值不为0,则第一个测试失败,控制传递给else子句。该子句运行第二个if语句,将day的值与1进行比较。

注意:只有第一个if测试为false,才执行第二个if语句。类似地,只有第一个if测试和第二个if测试为false,才执行第三个if。

if(day==0)

{

dayName=”Sunday”;

}

else if(day==1)

{

dayName=”Monday”;

}

else

{

dayName=”unKnown”;

}

3.3 使用switch语句

3.3.1 理解switch语句的语法

switch语句语法如下(switchcasedefaultC#关键字):

switch(controllingExpression)

{

case controllingExpression:

 statements

 break;

case controllingExpression:

 statements

 break;

default:

 statements

 break;

}

注意:每个controllingExpression值都必须唯一,使controllingExpression只能与它们当中的每一个匹配。如果controllingExpression的值和任何controllingExpression的值都不匹配,也没有default标签,程序就从switch的结束大括号之后的第一个语句继续执行。

例如,前面的嵌套if语句可改成switch语句:

switch(day)

{

case 0:

dayName=”Sunday”;

break;

case 1:

dayName=”Monday”;

break;

default:

dayName=”unKnown”;

break;

}

3.3.2 遵守switch语句的规则

switch语句很有用,但使用须谨慎。

switch语句要严格遵循以下规则:

l switch语句的控制表达式只能是某个整型(int,char,long等)或string。其它任何类型(包括float和double类型)只能用if语句。

l case标签必须是常量表达式,如42(控制表达式是int),‘4’(控制表达式是char)或“42”(控制表达式是string)。要在运行时计算case标签的值,必须使用if语句。

l case标签必须唯一,不允许两个case标签具有相同的值。

可以连续写多个case标签(中间不插额外语句),指定在多种情况下都运行相同的语句。如果像以下这样写,最后一个case标签之后的代码将适用于所有case。但假如两个标签之间有额外的代码,就不能从第一个标签贯穿(也称直通)到第二个标签,编译器会报错。例如:

switch(trumps)

{

case Hearts;

case Diamonds: //允许直通——标签之间无额外代码

 Color=”Red”; //Hearts和Diamonds两种情况都执行相同的代码

 break;

case Clubs:

 Color=”Black”;

case Spades:  //出错——标签之间有额外代码

 Color=”Black”;

 break;

}

注意:break语句是阻止直通的最常见方式,也可用return或throw语句代替。return从switch语句所在的方法退出,throw抛出异常并中止switch语句。

switch语句的直通规则:

如果间插了额外语句,就不能从一个case直通到下个case,这样就可以自由安排switch语句的各个区域,不用担心会改变其含义(就连default标签都能随意摆放;它通常放在最后,但并非必须)。

第4章 使用复合赋值和循环语句

4.1 使用复合赋值操作符

任何算术操作符都可以像这样与赋值操作符合并,从而获得复合赋值操作符

不要这样写

要这样写

Variable=Variable*number;

Variable*=number;

Variable=Variable/number;

Variable/=number;

Variable=Variable%number;

Variable%=number;

Variable=Variable+number;

Variable+=number;

Variable=Variable-number;

Variable-=number;

提示:复合赋值操作符具有和简单赋值操作符(=)一样的优先级和右结合性。

注意:变量递增或递减1不要使用复合赋值操作符,而是使用操作符++和--。

例如,不要这样写:

Count+=1;

而是这样写:

Count++;

 

4.2 使用while语句

使用while语句,可在条件为true时重复运行一个语句。While语句的语法如下:

while(booleanExpression)

statement

先求值booleanExpression(布尔表达式,注意必须放在圆括号中),为true就运行语句(statement)。再次求值booleanExpression,仍为true就再次运行语句。再次求值...如此反复,直至求值为false,此时while语句退出,从while构造后的第一个语句继续。

while语句在语法上和if语句相似(事实上,除关键字不同,语法完全一样),具体如下。

l 表达式必须是布尔表达式。

l 布尔表达式必须放在圆括号中。

首次求值布尔表达式为false,语句不运行。

要在while的控制下执行两个或更多语句,必须用大括号将语句分组成代码块。

正确while语句写法:

注意:while循环的变量i控制循环次数。这是常见的设计模式,具有这个作用的变量有时也成为哨兵变量

4.3 编写for语句

for语句提供了结构更正式版本,它将intialization(初始化)、booleanExpression(布尔表达式)与update control variable(更新控制变量)合并到一起。用过for语句就能体会到它的好处,它能防止遗漏初始化和更新控制变量的代码,减小写出无限循环代码的机率。以下是for语句的语法:

for(intialization;booleanExpression;update control variable)

statement

其中,statement(语句)是for循环主体,要么是一个语句,要么是用大括号{}封闭的代码块。

正确for语句写法:

注意:1.初始化只发生一次;2.初始化后先执行循环主体语句,再更新控制变量;3.更新控制变量后再重新求值布尔表达式。

4.4 编写do语句

do语句它的布尔表达式在每次循环之后求值,所以主体至少执行一次。

do语句的语法如下(不要忘记最后的分号):

do

statement

while(booleanExpression);

多个语句构成的循环主体必须是放在{}中的代码块。以下语句向控制台输出0~9,这次使用do语句:

break和continue语句

break可跳出switch语句。还可用它跳出循环。执行break后,系统立即终止循环,并从循环之后的第一个语句继续执行。在这种情况下,循环的“更新”和“继续”条件都不会重新判断。

相反,continue语句造成当前循环结束,立即开始下一次循环(在重新求值布尔表达式之后)。下面是在控制台上输出0~9的例子的另外一个版本,这次使用break语句和continue语句:

(建议慎用continue语句,或者根本不用,因为它很容易造成难以理解的代码。continue语句的行为还让人捉摸不透。例如,在for语句中执行continue语句,会在运行for语句的“更新(控制变量)”部分之后,才开始下一次循环。)

第5章 管理错误和异常

5.1 处理错误

早期系统(UNIX)采用的典型方案要求在每次方法出错时都由操作系统设置一个特殊全局变量。每次调用方法后都检查全局变量,判断方法是否成功。

和大多数面向对象编程语言一样,C#没有使用这种痛苦的、折磨人的方式处理错误。

相反,它使用异常。为了写健壮的C#应用程序,必须很好地掌握异常。

5.2 尝试执行代码和捕捉异常

C#中利用异常和异常处理程序,可以很容易地区分实现程序主逻辑的代码与处理错误的代码。为了写支持异常处理的应用程序,要做下面两件事:

  1. 代码放到try块中(try是C#关键字)。代码运行时,会尝试执行try块内的所有语句。如果没有任何语句产生异常,这些语句将一个接一个运行,直到全部完成。但一旦出现异常,就跳出try块,进入一个catch处理程序中执行。
  1. 紧接着try块写一个或多个catch处理程序(catch也是C#关键字)来处理可能发生的错误。每个catch处理程序都捕捉并处理特定类型的异常,可在try块后面写多个catch处理程序。try块中的任何语句造成错误,“运行时”都会生成并抛出异常。然后,“运行时”检查try块之后的catch处理程序,将控制权移交给匹配的处理程序。

异常处理写法,例如下:

try

{

//运行语句

}

catch(FormatException ex)

{

//处理异常

}

5.3 使用checked和unchecked整数

5.3.1 编写checked、unchecked语句

checked语句是以checked关键字开头的代码块。checked语句中的任何整数运算溢出都抛出OverflowException异常,如下例所示:

重要提示:只有直接在checked块中的整数运算才会检查。例如,对于块中的方法调用,不会检查所调用方法中的整数运算。

还可用unchecked关键字创建强制不检查溢出的代码块。Unchecked块中的所有整数运算都不检查,永远不抛出OverflowException异常。例如:

5.3.2 编写checked、unchecked表达式

还可使用checked和unchecked关键字控制单独整数表达式的溢出检查。只需用圆括号将表达式封闭起来,并在之前附加checked或unchecked关键字。如下例:

重要提示:不能使用checked和unchecked关键字控制浮点(非整数)运算。checked和unchecked关键字只适合int和long等整型运算。浮点运算永远不抛出OverflowException异常。

5.4 抛出异常

throw语句用于抛出异常对象,对象包含异常的细节。

5.5 使用finally块

当需要释放资源时,用try...catch就不是那么的合适,那么我们就会用finally块,例如:

TextReader reader=...;

...

try

{

String line=reader.ReadLine();

While(line !=null)

{

...

Line=reader.ReadLine();

}

}

finally

{

if(reader !=null)

{

Reader.Dispose();

}

}

即使读取文件时发生异常,finally块也保证reader.Dispose语句得到执行。

第II部分 理解C#对象模型

6章创建并管理类和对象

6.1封装的目的

封装是定义类时的重要原则。它的中心思想是:使用类的程序不关心类内部如何工作。程序只需创建类的实例并调用类的方法。

封装有时会信息隐藏,它实际有两个目的:

l 将方法和数据合并到类中,也就是为了支持分类;

l 控制对方法和数据的访问,也就是为了控制类的使用。

6.2定义并使用类

类主体包含的是一般的方法(如Area)和字段(如radius)。C#术语将类中的变量成为字段。(前面基础章节有写过关于变量、方法如何编写。如果有遗忘,返回第I部分继续回顾基础章节。)

定义类,如下例:

6.3控制可访问性

Public和Private关键字修改字段或方法的定义,决定它们是否能从外部访问。

只能从类内部访问的方法或字段是私有的。声明私有方法或字段需要在声明前添加private关键字。

方法或字段如果既能从类的内部访问,也能从外部访问,就说它是公共的。声明公共方法或字段需要在声明前添加public关键字。

以下是修改过的Test类。这次Area方法声明为公共方法,radius声明为私有字段:

命名和可访问性

许多企业规定了自己的编码样式,标识符命名是其中的一环,目的是加强代码的可维护性。出于对类成员可访问性的考虑,推荐采用以下字段和方法命名规范(C#未强制这些规范)。

l 公共标识符以大写字母开头。例如Area以A而非a开头,因为它是公共的。这是所谓的PascalCase命名法(因为最早在Pascal语言中使用)。

非公共标识符(包括局部变量)以小写字母开头。例如radius以r而非R开头,因为它是私有的。这是所谓的camelCase命名法。

有的企业只将camelCase命名法用于方法,私有字段以下划线开头,例如_radius。

类名以大写字母开头,构造器必须完全和类同名,所以私有构造器也以大写字母开头。

6.3.1使用构造器

构造器是在创建类的实例时自动运行的方法。它与类同名,能获取参数,但不能返回任何值(即使是void)。每个类至少要有一个构造器。如果不提供构造器,编译器会自动生成一个什么都不做的默认构造器。自己写默认构造器很容易—添加与类同名的公共方法,不返回任何值就可以了。下例展示了有默认构造器的Test类,这个自己写的构造器能将radius字段初始化为0:

6.3.2重载构造器

重载构造器,如下例:

然后可在新建Test对象时调用该构造器,如下所示:

Test t;

t=new Test(45);

6.4理解静态方法和数据

静态方法不依赖类的实例,不能访问类的任何实例字段或实例方法。相反,只能访问标记为static的其他方法和字段。

6.4.1创建共享字段

静态字段能在类的所有对象之间共享(非静态字段则局部于类的实例)。在下例中,每次新建Test对象,Test构造器都使Test类的静态字段NumCircles递增1:

C#术语中,静态方法也称为“类方法”。但静态字段通常不叫“类字段”,就是叫“静态字段”或者“静态变量”。

6.4.2使用const关键字创建静态字段

const关键字声明的字段称为常量字段,是一种特殊的静态字段,它的值永远不会改变。关键字const是“constant”(常量)的简称。Const字段虽然也是静态字段,但声明时不要static关键字。只有数值类型(例如int或double)

6.4.3理解静态类

C#允许声明静态类。静态类只能包含静态成员(使用该类创建的所有对象都共享这些成员的单一拷贝)。使用new操作符创建静态类的对象没有意义,编译器会报错。为了执行初始化,静态类允许包含一个默认构造器,前提是该构造器也被声明为静态。其它任何类型的构造器都是非法的,编译器会报错。

6.4.4匿名类

匿名类是没有名字的类。

创建匿名类的办法是以new关键字开头,后跟一对{},在大括号中定义想在类中包含的字段和值,如下所示:

myAnonymousObject=new {Name=”Jonhn”,Age=37};

定义匿名类时,编译器为该类生成只有它自己知道的名称。

匿名类的意义就是根本不知道类型是什么。

但使用var关键字将myAnonymousObject声明为隐式类型的变量,问题就解决了,如下所示:

var myAnonymousObject=new {Name=”Jonhn”,Age=37};

7章理解值和引用

在这第七章中有比较多的理论需要去理解,因为有时候一段代码并不能很好解释这个功能或者一个知识点,只有用理论去大篇幅解释这些内容,所以我们在实践的同时也要关注理论知识,对我们来说这个就是说明书,而代码只是输出操作语句,用的VS只是工具。那么我们要看懂说明书,才能只能怎么玩工具,做什么操作。废话不多说,just do it。

7.1复制值类型的变量和类

C#大多数基元类型(包括int,float,double和char等,但不包括string)都是值类型。将变量声明为值类型,编译器会生成代码来分配足以容纳这种值的内存块。

引用类型的复制与私有数据

类可以提供Clone方法来返回自己的新实例,并填充相同的数据。Clone方法能访问对象的私有数据,并直接将数据复制到同一个类的另一个实例中。例如,Circle类的Clone方法可以像下面这样去定义:

如果所有私有数据都是值类型,这个方式没有任何问题。但如果包含任何引用类型的字段(例如private int radius=0;),需要提供Clone方法,否则Circle类的Clone方法只是复制对这些字段的引用。只复制引用称为“浅拷贝”。如果提供了Clone方法,能够复制引用的对象,就称为“深拷贝”。

Private关键字创建了不能从类外访问的字段或方法。但是,这并不是说它只能由单个对象访问。创建同一个类的两个对象,它们分别能访问对方的私有数据。所以,“私有”实际是指“在类的级别上私有”,而非“在对象级别上私有”。另外,私有和静态是两码事。

重点:字段声明为私有,类的每个实例都有一份自己的数据。声明为静态,每个实例都共享同一份数据。

7.2理解null值和可空类型

C#允许将null值赋给任意引用变量。值为null的变量表明该变量不引用内存中的任何对象。如下:

Circle c=new Circle(42);

Circle copy=null;  //声明的同时进行初始化,这是好的编程实践

if(copy==null)

{

copy=c;  //copy和c引用同一个对象

}

空条件操作符

检测Circle对象是否为null,再决定是否调用Area方法:

if(c !=null)

{

Console.WriteLine($”The area of circle c is {c.Area()}”);

}

如果c为空,就不会向命令提示符窗口写入任何内容。还可在尝试调用Circle.Area之前在Circle对象上使用空条件操作符:

Console.WriteLine($”The area of circle c is {c?.Area()}”);

空条件操作符告诉“运行时”在操作符所应用的变量为null的前提下忽略当前语句。在本例中,命令提示符窗口会显示以下文本:

The area of circle c is

两种方式均有效,可满足你在不同情况下的需要。

7.2.1使用可空类型

利用C#定义的一个修饰符,可将变量声明为可空值类型。可空值类型在行为上与普通值类型相似,但可以将null值赋给它。要用问号(?)指定可空值类型,如下所示:

int? i=null;  //合法

7.2.2理解可空类型的属性

可空类型公开了两个属性,用于判断类型是否实际包含非空的值,HasValue属性判断可空类型是包含一个值,还是包含null。如果包含值,可用Value属性获取该值。如下所示:

注意:可空类型的Value属性是只读的。可利用这个属性来读取变量的值,但不能修改。若修改要用普通的赋值语句。

7.3使用ref和out参数

例如下:

如果一个方法的参数(形参)是引用类型,那么使用那个参数来进行的任何修改都会改变传入的实参所引用的数据。虽然引用的数据发生了改变,但传入的实参没有变——它仍然引用同一个对象。

虽然可以通过参数来修改实参引用的对象,但不可能修改实参本身。少数情况下,我们希望方法能实际地修改一个实参。C#语言专门提供了ref和out关键字。

7.3.1创建ref参数

为参数(形参)附加ref前缀,C#编译器将生成代码传递对实参的引用,而不是传递实参的拷贝。使用ref参数,作用于参数的所有操作都会作用于原始实参,因为参数和实参引用同一个对象。

作为ref参数传递的实参也必须附加ref前缀。这个语法明确告知开发人员实参可能改变。例如下:(这次使用了ref关键字)

注意:ref引用时,如果int arg;未初始化则代码无法编译。

7.3.2创建out参数

关键字out是output(输出)的简称。向方法传递out参数之后,必须在方法内部对其进行赋值,如下例所示:

由于out参数必须在方法中赋值,所以调用方法时不需要对实参进行初始化。例如,以下代码调用doInitialize来初始化变量arg,然后再输出它的值:

注意:ref和out修饰符除了能应用于值类型的参数,还能应用于引用类型的参数。效果完全一样。参数将成为实参的别名。

7.4计算机内存的组织方式

操作系统和“运行时”通常将用于容纳数据的内存划分为两个独立的区域,每个区域都以不同方式管理。这两个区域通常称为

l 调用方法时,它的参数和局部变量所需的内存总是从栈中获取。方法结束后,要么正常返回,要么抛出异常,所以为参数和局部变量分配的内存将自动归还给栈,并可在另一个方法调用时重新使用。栈上的方法参数和局部变量具有良好定义的生存期。方法开始时进入生存期,方法结束时结束生存期。

使用new关键字创建对象(类的实例)时,构造对象所需的内存总是从堆中获取。使用引用变量,可以从多个地方引用同一个对象。对象的最后一个引用消失之后,对象占用的内存就可供重用(不一定被立即回收)。堆上创建的对象具有较不确定的生存期;使用new关键字将创建对象,但只要在删除了最后一个对象引用之后的某个不确定时刻,它才会消失。

思考调用以下方法会发生什么:

void Method(int param)

{

Circle c;

c=new Circle(param);

...

}

假定传给param的值是42。调用方法时,栈中将分配一小块内存(刚够存储一个int),并用值42初始化。在方法内部,还有从栈中分配出另一小块内存,它刚够存储一个引用(一个内存地址),只是暂时不进行初始化(它是为Circle类型的变量c准备的。)接着,要从堆中分配一个足够大的内存区域来容纳一个Circle对象。这正是new关键字所执行的操作:它允许Circle构造器,将这个原始的堆内存转换成Circle对象。对这个Circle对象的引用将存储到变量c中。

注意:

虽然对象本身存储在堆中,但对象引用(变量c)存储在栈中。

堆内存是有限的资源。堆内存耗尽,new操作符抛出OutOfMemoryException,对象创建失败。

Circle析构器也可能抛出异常,在这种情况下,分配给Circle对象的内存会被回收,析构器返回null值。

7.5System.Object类

.NET Framework 最重要的引用类型之一是System命名空间中的Object类。所有类都是System.Object的派生类;另外,System.Object类型的变量能引用任何对象。由于System.Object相当重要,所有C#提供了object关键字来作为System.Object的别名。

实际写代码时,既可以写成object,也可以写成System.Object,两者没有区别。

7.6装箱

Object类型的变量能引用任何引用类型的任何对象。除此之外,object类型的变量也能引用值类型的实例。例如下:

int i=42;

object o=i;

i是值类型,所以它在栈中。如果o直接引用i,那么引用的将是栈。然而,所以引用都必须引用堆上的对象;如果引用栈上的数据项,会严重损害“运行时”的健壮性,并造成潜在的安全漏洞,所以是不允许的。实际发生的事情是“运行时”在堆中分配一小块内存,然后i的值被复制到这块内存中,然后让o引用该拷贝。这种将数据项从栈自动复制到堆的行为称为装箱

7.7拆箱

为了访问已装箱的值,必须进行强制类型转换,简称转型。这个操作会先检查是否能将一种类型安全转换成另一种类型,然后才执行转换。为了进行转型,要在object变量前添加一对圆括号,并输入类型名称,如下例所示:

int i=42;

object o=i;  //装箱

i=(int)0;  //成功编译

如果o真的引用一个已装箱的int,转型就会成功执行,编译器生成的代码会从装箱的int中提取出值(本例是将装箱的值再存回i)。这个过程称为拆箱

7.8数据的安全转型

C#语言提供了两个相当有用的操作符,能以更得体的方式执行转型,这就是is操作符和as操作符。

7.8.1is操作符

is操作符验证对象的类型是不是自己希望的,如下所示:

WrappedInt wi=new WrappedInt();

object o=wi;

if(o is WrappedInt)

{

WrappedInt temp=(WrappedInt)o;//转型是安全的;o确定是一个WrappedInt

}

7.8.2as操作符

as操作符充当了和is操作符类似的角色,只是功能稍微进行了删减。如下所示:

WrappedInt wi=new WrappedInt();

object o=wi;

WrappedInt temp=o as WrappedInt;

if(temp !=null)

{

//转型成功,这里的代码才会执行

}

转换失败,as表达式的求值结果为null,这个值也会被赋给temp。

8章使用枚举和结构创建值类型

8.1使用枚举

在我们定义一些无法用数字去代替比如春(Spring)夏(Summer)秋(Fall)冬(Winter)时,C#提供了更好的方案。可以使用enum关键字创建枚举类型,限制其值只能是一组符号名称。

8.1.1声明枚举

如下例:

8.1.2使用枚举

如下例:

8.1.3选择枚举字面值

例如,下例在控制台上输出值2,而不是单词Fall(Spring对应0,Summer对应1,Fall对应2,Winter对应3):

重要提示:用于初始化枚举字面值的整数值必须是编译时能确定的常量值(例如1)。

Spring对应1,Summer对应2,Fall对应3,Winter对应4。

8.1.4选择枚举的基础类型

声明枚举时,枚举字面值默认是int类型。但是,也可让枚举类型基于不同的基础整型。例如,为了声明Season的基础类型是short而不是int,可以像下面这样写:

这样的目的是节省内存。int占用内存比short大;如果不需要int那么大的取值范围,就可考虑使用较小的整型。

枚举可基于8种整型的任何一种:byte,sbyte,short,ushort,int,uint,long或者ulong。

8.2使用结构

类定义的是引用类型,总是在堆上创建。有时类只包含极少数据,因为管理堆而造成的开销显得不合算。这时更好的做法是将类型定义成结构。结构是值类型,在栈上存储,能有效减少内存管理的开销(当然前提是这个结构足够小)。

结构可包含自己的字段、方法和构造器。

8.2.1声明结构

声明结构要以struct关键字开头,后跟类型名称,最后是大括号中的结构主体。语法上和声明类是一样的。例如,下面是一个名为Time的结构,其中包含三个公共的int字段,分别是hours,minutes和seconds:

和类一样,大多数时候都不要在结构中声明公共字段,因为无法控制它的值。更好的做法是使用私有字段,并为结构添加构造器和方法来初始化和处理这些字段。如下例所示:

提示:如果一个概念的重点在于值而非功能,就用结构来实现。

8.2.2理解结构和类的区别

结构和类在语法上极其相似,但两者也存在一些重要区别,具体如下。

l 不能为结构声明默认构造器(无参构造器)。

l 类的实例字段可在声明时初始化,但结构不允许。

问题

结构

是值类型还是引用类型?

结构是值类型

类是引用类型

它们的实例存储在栈上还是堆上?

结构的实例称为值,存储在栈上

类的实例称为对象,存储在堆上

可以声明默认构造器吗?

不可以

可以

如果声明自己的构造器,编译器仍会生成默认构造器吗?

不会

如果在自己的构造器中不初始化一个字段,编译器自动初始化吗?

不会

实例字段可以在声明时初始化吗?

不可以

可以

8.2.3声明结构变量

定义好结构类型之后,可以像使用其他任何类型那样使用它们。例如,如果定义了名为Time的结构,就可以创建Time类型的变量、字段和参数。如下例所示:

注意:和枚举一样,可以使用?修饰符创建结构变量的可空版本。然后,可以把null值赋给变量。

Time? currentTime=null;

8.2.4理解结构的初始化

调用构造器,前面描述的规则将保证结构中的所有字段都得到初始化:

Time now=new Time();

如果写了自己的struct构造器,也可以用它来初始化结构变量。如前所述,必须在自己的构造器中显式初始化结构的全部字段。例如:

下例调用自定义的构造器来初始化Time类型的变量now:

8.2.5复制结构变量

可将结构变量初始化或赋值为另一个结构变量,前提是赋值操作符=右侧的结构变量已完全初始化(换言之,所有字段都用有效数据填充,而不是包含未定义的值)。例如,下例能成功编译,因为now已完全初始化。

Time now=new Time(1,12,19);

Time copy=now;

复制结构变量时,=操作符左侧的结构变量的每个字段都直接从右侧结构变量的对应字段复制。这是一个简单的复制过程,它对整个结构的内容进行复制,而且绝不会抛出异常。而如果Time是类,两个变量(now和copy)将引用堆上的同一个对象。

9章使用数组

9.1声明和创建数组

数组是无序的元素序列。数组中的所有元素都具有相同类型。数组中的元素存储在一个连续性的内存块中,并通过索引来访问。

9.1.1声明数组变量

例如下,以下语句声明名为pins的数组,数组中包含int类型的变量:

int[] pins;

9.1.2填充和使用数组

例如下:

int[] pins=new int[4]{9,2,1,3};

初始化数组变量时可以省略new表达式和数组大小。编译器根据初始值的数量来计算大小,并生成代码来创建数组。例如:

9.1.3创建隐式类型的数组

声明数组时,元素类型必须与准备存储的元素类型匹配。

如下所示:

Var names=new[]{”John”,”Dina”,”James”,”Jan”};

以下代码创建由匿名对象构成的数组,其中每个对象都包含两个字段:

匿名类型中的字段对于每个数组元素都必须相同。

9.1.4访问单独的数组元素

必须通过索引来访问单独的数组元素。数组索引基于零,第一个元素的索引是0而不是1。索引1访问的是第二个元素。例如下:

9.1.5遍历数组

foreach语句,例如下:

foreach语句更明确表达了代码的目的,而且避免了使用for循环的麻烦。但少数情况下for语句更佳,如下所示:

l foreach语句总是遍历整个数组。如果只想遍历数组的一部分,或者希望中途跳过特定元素,那么使用for语句将更容易。

l foreach语句总是从索引0遍历到所有length-1。要反向或者以其他顺序遍历,更简单的做法是使用for语句。

如果循环主体需要知道元素的索引,而非只是元素的值,就必须使用for语句。

修改数组元素必须使用for语句。这是因为foreach语句的循环变量是数组的每个元素的只读拷贝。

9.1.6使用多维数组

例如,二维数组是包含两个整数索引的数组。以下代码创建包含24个整数的二维数组items。可将二维数组想象成表格,第一维是表行,第二维是表列。

访问二维数组元素需提供两个索引值来制定目标元素的“单元格”(行列交汇处)。以下代码展示了items数组的用法:

9.1.7创建交错数组

C#中,普通多维数组有时也称为矩形数组。例如,下面这个表格式二维数组每一行都包含40个元素,共计160个元素。

多维数组可能消耗大量内存。如果只使用每一列的部分数据,为未使用的元素分配内存就是巨大的浪费。这时可以考虑使用交错数组(或成为不规则数组),其每一列的长度都可以不同,如下所示:

本例第一列3个元素,第二列10个元素,第三列40个元素,最后一列25个元素。交错数组其实就是由数组构成的数组。和二维数组不同,交错数组只有一维,但那一维中的元素本身就是数组。除此之外,items数组的总大小是78个元素而不是160个元素,不用的元素不分配空间。

10章理解参数数组

10.1回顾重载

重载是指在同一个作用域中声明两个或更多同名方法,适合对不同类型的实参执行相同的操作。

10.2使用数组参数

假定要写方法判断作为实参传递的一组值中的最小值。一个办法是使用数组。例如,为了查找几个int值中最小的,可以写名为Min的静态方法,向其传递一个int数组,如下所示:

Min方法判断两个int变量(first和second)的最小值可以这样写:

用Min方法判断三个int变量(first,second和third)的最小值可以这样写:

可以看出,这个方案避免了对大量重载的需求,但也为此付出了代价:必须写额外的代码来填充传入的数组。当然,也可以像下面这样使用匿名数组:

10.2.1声明参数数组

参数数组允许将数量可变的实参传给方法。为了定义参数数组,要用params关键字修饰数组参数。例如下面这个修改过的Min方法。这次它的数组参数被声明成参数数组:

Params关键字对Min方法的影响是:调用该方法时,可以传递任意数量的整数实参,而不必担心创建数组的问题。例如,要判断两个整数值哪个最小,可以像下面这样写:

编译器自动将上诉调用转换成如下所示的代码:

以下代码判断三个整数哪个最小,它同样被编译器转换成使用了数组的等价代码:

参数数组需注意以下几点。

只能为一维数组使用params关键字,不能用于多维数组,以下代码不能编译:

//编译时错误

Public static int Min(params int[,] table)

...

不能只依赖params关键字来重载方法。params关键字不是方法签名的一部分,如下例所示:

//编译时错误:重复的声明

Public static int Min(int[] paramList)

...

Public static int Min(params int[] paramList)

...

不允许为参数数组指定ref和out修饰符,如下例所示:

//编译时错误

Public static int Min(ref params int[] paramList)

...

Public static int Min(out params int[] paramList)

...

l Params 数组必须是方法最后一个参数。这意味着每个方法只能有一个参数数组。如下例所示:

//编译时错误

Public static int Min(params int[] paramList,int i)

...

params方法总是优先于params方法。也就是说,如果愿意,仍然可以创建方法的重载版本以便在常规情况下使用:

Public static int Min(int leftHandSide,int rightHandSide)

...

Public static int Min(params int[] paramList)

...

调用Min时传递两个int实参,就使用Min的第一个版本。传递其它任意数量的int实参(包括无任何实参的情况),就使用第二个版本。为方法声明无参数数组的版本或许是一个有用的优化技术。这样能避免编译器创建和填充太多的数组。

10.2.2使用params object[]

int类型的参数数组很有用,它允许在方法调用中传递任意数量的int参数。但是,如果参数数量不固定,参数类型也不固定,又该怎么办?C#也为此提供了解决之道,该技术基于这样一个事实:object是所有类的根,编译器通过装箱将值类型(那些不是类的东西)转换成对象。可让方法接收object类型的一个参数数组,从而接收任意数量的object实参;换言之,不仅实参数量任意,参数类型也可以任意,如下例所示:

任何实参都不能从中逃脱。

不向它传递任何实参,这时编译器传递长度为0的object数组:

Black.Hole();

//转换成Black.Hole(new object[0]);

传递null作为实参。数组是引用类型,所以允许使用null来初始化数组:

Black.Hole(null);

l 传递一个实际的数组。也就是说,可以手动创建本应由编译器创建的数组:

object[] array=new object[2];

array[0]=”forty two”;

array[1]=42;

Black.Hole(array);

传递不同类型的实参,这些实参自动包装到object数组中:

Black.Hole(”forty two”,42);

//转换成Black.Hole(new object[]{”forty two”,42});

10.3比较参数数组和可选参数

从表面看,获取参数数组的方法和获取可选参数的方法似乎存在着一定程度的重叠。然而,两者有着根本的不同。

l 获取可选参数的方法仍然有固定参数列表,不能传递一组任意的实参。编译器会生成代码,在方法运行前,为任何遗漏的实参在栈上插入默认值。方法不关心哪些实参是由调用者提供的,哪些是由编译器生成的默认值。

l 使用参数数组的方法相当于有一个完全任意的参数列表,没有任何参数有默认值。此外,方法可准确判断调用者提供了多少个实参。

通常,如果方法要获取任意数量的参数(包括0个),就使用参数数组。只有在不方便强迫调用者为每个参数都提供实参时才使用可选参数。

11章使用继承

11.1什么是继承

继承是面向对象编程的关键概念。如果不同的类有通用的功能,而且这些类相互之间的关系很清晰,那么利用继承能避免大量重复性工作。

11.2使用继承

以下面两个类都继承了Mammal类,如下所示:

重要提示:继承只适用于类,不适用于结构。不能定义由结构组成的继承链,也不能从类或其他结构派生出一个结构。

11.2.1复习System.Object类

System.Object类是所有类的根。所有类都隐式派生自System.Object类。所有,C#编译器会悄悄地将Mammal类重写为以下代码:

System.Object类的所有方法都沿着继承链向下传递给从Mammal派生的类。换言之,你定义的所有类都会自动继承System.Object类的所有功能,其中包括ToString方法,它将object转换成string以便显示。

11.2.2调用基类构造器

作为好的编程实践,派生类的构造器在执行初始化时,最好调用一下它的基类构造器。为派生类定义构造器时,可以使用base关键字调用基类构造器。如下例所示:

不在派生类构造器中显式调用基类构造器,编译器会自动插入对基类的默认构造器的调用,然后才会执行派生类构造器的代码。

特别注意:如果有公共默认构造器,就能成功编译。但是,并非所有类都有公共默认构造器(记住,只有在没有写任何非默认构造器的前提下,编译器才会自动生成一个默认构造器);在这种情况下,如果忘记调用正确的基类构造器,就会造成编译时错误。

11.2.3类的赋值

可以将一种类型的对象赋给继承层次结构中较高位置的一个类的变量,以下语句合法:

Horse myHorse=new Horse();

Mammal myMammal=myHorse;  //合法,因为Mammal是Horse的基类

myMammal.Breathe();       //这个调用合法,Breathe是Mammal类的一部分

注意:这就解释了为什么一切都能赋给Object变量。记住,object是System.Object的别名,所有类都直接或间接从System.Object继承。

不能直接将Mammal对象赋给Horse变量:

Mammal myMammal=new Mammal();

Horse myHorse=myMammal;//错误

要先检查,确认该Mammal确实是Horse。这个检查是使用as或is操作符,或者通过强制类型转换来进行的。下例使用as操作符检查myMammal是否引用一个Horse,如果是,对myHorseAgain进行赋值后,myHorseAgain将引用那个Horse对象;如果myMammal引用的是其他类型的Mammal,as操作符就会返回null。如下例所示:

Horse myHorse=new Horse();

Mammal myMammal=myHorse;     //myMammal引用一个Horse

Horse myHorseAgain=myMammal as Horse; //通过-myMammal确实是一个Horse

Whale myWhale=new Whale();

myMammal=myWhale;

myHorseAgain=myMammal as Horse; //返回null-myMammal不是Horse而是Whale

11.2.4声明新方法

注意:方法签名由方法名、参数数量和参数类型共同决定,方法的返回类型不计入签名。两个同名方法如果获取相同的参数列表,就说它们有相同的签名,即使它们的返回类型不同。

派生类中的方法会屏蔽(或隐藏)基类具有相同签名的方法。例如,编译以下代码时,编译器将显示警告信息,指出Horse.Talk方法隐藏了继承的Mammal.Talk方法:

Class Mammal

{

Public void Talk()

{}

}

Class Horse:Mammal

{

Public void Talk()

{}

}

虽然代码能编译并运行,但应该严肃对待该警告。如果另一个类从Horse派生,并调用Talk方法,它希望调用的可能是Mammal类实现的Talk,但该方法被Horse中的Talk隐藏了,所以实际调用的是Horse.Talk。应该重命名以避免冲突。然而,如果确实希望两个方法具有相同的签名,从而隐藏Mammal.Talk方法,可以明确使用new关键字消除警告:

Class Mammal

{

Public void Talk()

{}

}

Class Horse:Mammal

{

New public void Talk()

{}

}

像这样使用new关键字,隐藏仍会发生。它唯一的作用就是关闭警报。

11.2.5声明虚方法

重写方法被称为虚(virtual)方法。“重写方法”和“隐藏方法”的区别现在已经很明显了。重写是提供同一个方法的不同实现,这些方法相互关联,因为它们旨在完成相同的任务,只是不同的类用不同的方式。但是,隐藏是指方法被替换成另一个方法,方法通常不相关,而且可能执行完全不同的任务。对方法进行重写是有用的编程概念;而如果方法被隐藏,则意味着可能发生了一处编程错误。

虚方法用virtual关键字标记。例如,以下是system.object的ToString方法定义:

Namespace System

{

class Object

{

public virtual string ToString()

{

...

}

...

}

...

}

11.2.6声明重写方法

派生类用override关键字重写基类的虚方法,从而提供该方法的另一个实现,如下例所示:

class Horse:Mammal

{

...

public override string ToString()

{

...

}

}

在派生类中,方法的新实现可用base关键字调用方法的基类版本,如下所示:

Public override string ToString()

{

base.ToString();

...

}

使用virtual和override关键字声明多态性的方法时,必须遵守以下重要规则。

虚方法不能私有。这种方法目的就是通过继承向其他类公开。类似地,重写方法不能私有,因为类不能改变它继承的方法的保护级别。但是,重写方法可用protected关键字实现所谓的“受保护”保密性。

虚方法和重写方法的签名必须完全一致。必须具有相同的名称、相同的参数类型/数量。除了签名一致,两个方法还必须返回相同的类型。

只能重写虚方法。对基类的非虚方法进行重写会显示编译时错误。这个设计是合理的,应该由基类的设计者来决定方法是否能被重写。

如果派生类不用override关键字声明方法,就不是重写基类方法,而是隐藏方法。也就是说,成为和基类方法完全无关的另一个方法,该方法只是恰巧与基类方法同名。如前所述,这会造成编译时显示警告称该方法会隐藏继承的同名方法。前面说过,可以使用new关键字消除警告。

重写方法隐式地成为虚方法,可在派生类中被重写。然而,不允许用virtual关键字将重写方法显式声明为虚方法。

11.2.7理解受保护的访问

public和private关键字代表两种极端的可访问性:类的公共(public)字段和方法可由每个人访问,而类的私有(private)字段和方法只能由类自身访问。

如果只是孤立地考察一个类,这两种极端的访问完全够用了。但是,孤立的类解决不了复杂问题。继承是将不同的类联系到一起的重要方式,在派生类及其基类之间,明显存在一种特别而紧密的关系。经常都要允许基类的派生类访问基类的部分成员,同时阻止不属于该继承层次结构的类访问。这时就可以使用protected(受保护)关键字标记成员。

如果类A派生自类B,就能访问B的受保护成员。也就是说,在派生类A中,B的受保护成员实际是公共的。

如果类A不从类B派生,就不能访问B的受保护成员。也就是说,在A中,B的受保护成员实际是私有的。

C#允许程序员自由地将方法和字段声明为受保护。但大多数面向对象编程指南都建议尽量使用私有字段,只在绝对必要时才放宽限制。公共字段破坏了封装性,因为类的所有用户都能直接地、不受限制地访问字段。受保护字段虽然维持了封装性(类的用户无法访问受保护字段),但由于受保护字段在派生类中实际就是公共字段,所以这个封装性仍然可能被派生类破坏。

注意:不仅派生类能访问受保护的基类成员,派生类的派生类也能。受保护的基类成员在继承层次结构的任何派生类中都能访问。

11.3理解扩展方法

继承很强大,允许从一个类派生出另一个类来扩展类的功能。但有时为了添加新的行为,继承并不一定是最佳方案,尤其是需要快速扩展类型,又不想影响现有代码的时候。

例如,假定要为int类型添加新功能,比如一个名为Negate的方法,它返回当前整数的相反数。我知道可以使用一元求反操作符(-)来做这件事情。但请先不要管它。为此,一个办法是定义新类型NegIn32,让它从System.Int32派生(int是System.Int32的别名),在派生类中添加Negate方法:

class NegInt32:System.Int32 //别这样写!

{

public int Negate()

{

  ...

}

}

NegInt32理论上应继承System.Int32类型的所有功能,并添加自己的Negate方法。但基于以下两方面的原因,这样写是行不通的。

新方法只适合NegInt32类型,要把它用于现有的int变量,就必须将每个int变量的定义修改成NegInt32类型。

l System.Int32是结构而不是类,而结构是不能继承的。

这正是扩展方法可以大显身手的时候。

扩展方法允许添加静态方法来扩展现有的类型(无论类还是结构)。引用被扩展类型的数据即可调用扩展方法。

扩展方法在静态类中定义,被扩展的类型必须是方法的第一个参数,而且必须附加this关键字。下例展示了如何为int类型实现Negate扩展方法:

Static class Util

{

Public static int Negate(this int i)

{

return -i;

}

}

语法看起来有点奇怪,但请记住:正是由于为Negate方法的参数附加了this关键字作为前缀,才表明这是一个扩展方法;另外,this修饰int,表明扩展的是int类型。

使用扩展方法只需让Util类进入作用域(如有必要,添加一个using语句,指定Util类所在的命名空间),然后就可以简单地使用点记号法来引用方法,如下所示:

int x=591;

Console.WriteLine($”x.Negate{x.Negate()}”);

注意,调用Negate方法时根本不需要引用Util类C#编译器自动检测当前在作用域中的所有静态类,找出为给定类型定义的所有扩展方法。此外,还可调用Util.Negate方法,将int值作为参数传递,这和以前用的普通语法是相同的。但这样便丧失了将方法定义成扩展方法的意义:

int x=591;

Console.WriteLine($”x.Negate{Util.Negate(x)}”);

12章创建接口和定义抽象类

从类继承是很强大的机制,但继承真正强大之处是能从接口继承。接口不包含任何代码或数据;它只规定了从接口继承的类必须提供哪些方法和属性。使用接口,方法的名称/签名可以和方法的具体实现完全隔绝。

抽象类在许多方面都和接口相似,只是它们可以包含代码和数据。然而,可以将抽象类的某些方法指定为虚方法,指示从抽象类继承的类必须以自己的方式实现这些方法。抽象类经常与接口配合使用,它们联合起来提供了一项关键性的技术,允许构建可扩展的编程框架。

12.1理解接口

现在抛出一个问题是:集合中的对象的排序方式应取决于对象本身的类型,而不是取决于集合。所以,正确解决方案是规定集合中的所有对象都必须提供一个可由集合的RetrieveInOrder方法调用的方法,以实现对象的相互比较,例如下面的CompareTo方法:

int CompareTo(object obj)

{

//如果this实例等于obj,就返回0

//如果this实例小于obj,就返回<0

//如果this实例大于obj,就返回>0

...

}

可定义一个接口来包含这个方法,规定只有实现了该接口的类才是集合类。这样一来,接口就相当于一份协议(contract)。类实现了接口后(签订了协议后),接口(协议)就能保证类包含了接口所指定的全部方法。这个机制保证可以为集合中的所有对象调用CompareTo方法,并对它们进行排序。

12.1.1定义接口

定义接口和定义类相似,只是使用interface而不是class关键字。在接口中按照与类和结构一样的方式声明方法,只是不允许指定任何访问修饰符(public,private和protected都不可以用)。另外,接口中的方法是没有实现的,它们只是声明。实现接口的所有类型都必须提供自己的实现。所以,方法主体被替换成一个分号。下面是一个例子:

interface IComparable

{

int CompareTo(object obj);

}

提示:Microsoft.NET Framework文档建议接口名称以大写字母I开头。这个约定是匈牙利记号法在C#中的最后一处残余。顺便说一句,System命名空间已经像上述代码描述的那样定义了IComparable接口。

接口不含任何数据;不可以向接口添加字段(私有的也不行)。

12.1.2实现接口

为了实现接口,需要声明类或结构从接口继承,并实现接口指定的全部方法。这不是真正的“继承”——虽然语法一样,而且如同本章稍后会讲到的那样,语义有继承的大量印记。注意,虽然不能从结构派生,但结构是可以实现接口的(从接口“继承”)。

例如,假定要定义第11章讲述的Mammal(哺乳动物)层次结构,但要求所有陆栖哺乳动物都提供名为NumberOfLegs(腿数)的方法,它返回一个int值,指出一种哺乳动物有几条腿。为此,可以定义一个ILandBound(land bound是指陆栖)接口,并在其中包含下面这个方法:

interface ILandBound

{

int NumberOfLegs();

}

然后可以在Horse(马)类中实现该接口,具体就是从接口继承,并为接口定义的所有方法提供实现(本例只有一个NumberOfLegs方法):

class Horse:ILandBound

{

...

public int NumberLegs()

{

return 4; //马有4条腿

}

}

实现接口时,必须保证每个方法都完全匹配对应的接口方法,具体遵循以下几个规则。

l 方法名和返回类型完全匹配。

所有参数(包括ref和out关键字修饰符)都完全匹配。

用于实现接口的所有方法都必须具有public可访问性。但如果使用显式接口实现(即实现时附加接口名前缀,稍后会解释),则不应该为方法添加访问修饰符。

接口的定义和实现存在任何差异,类都无法编译。

 

提示:Microsoft Visual Studio IDE能帮助你实现接口方法。“实现接口”向导为接口定义的每个方法生成存根。你只需用恰当的代码填充存根。

一个类可以在从一个类继承的同时实现接口。在这种情况下,C#不像Java那样用关键字as来区分基类和接口。相反,C#根据位置来区分。首先写基类名,再写逗号,最后写接口名。例如,下例定义Horse从Mammal继承,同时实现ILandBound接口:

interface ILandBound

{

...

}

class Mammal

{

...

}

class Horse:Mammal,ILandBound

{

...

}

注意:一个接口(InterfaceA)可以从另一个接口(InterfaceB)继承。这在技术上称为接口扩展而不是继承。在本例中,实现InterfaceA的类或结构必须实现两个接口的定义的方法。

12.1.3通过接口来引用类

和基类变量能引用派生类对象一样,接口变量也能引用实现了该接口的类的对象。例如,ILandBound变量能引用Horse对象,如下所示:

Horse myHorse=new Horse(...);

ILandBound iMyHorse=myHorse;//合法

通过接口来引用对象是一项相当有用的技术。因为能由此定义方法来获取不同类型的实参

——只要类型实现了指定的接口。例如,以下FindLandSpeed方法可获取任何实现了ILandBound接口的实参:

int FindLandSpeed(ILandBound landBoundMammal)

{

...

}

可用is操作符验证对象是实现了指定接口的一个类的实例。除了适用于类和结构,它还使用于接口。

例如,以下代码检查myHorse变量是否实现了ILandBound接口,如果是就把它赋给一个ILandBound变量。

if(myHorse is ILandBound)

{

ILandBound iLandBoundAnimal=myHorse;

}

注意,通过接口引用对象时,只有通过接口可见的方法才能被调用。

12.1.4使用多个接口

一个类最多只能有一个基类,但可以实现数量不限的接口。类必须实现这些接口声明的所有方法。

结构或类要实现多个接口,接口要以逗号分隔。如果还要从一个基类继承,那么接口要在基类之后列出。例如,假定已定义了一个IGrazable(草食)接口,它包含ChewGrass(咀嚼草)方法,规定所有草食类动物都要实现自己的ChewGrass方法。

在这种情况下,可以像下面这样定义Horse类,它表明Mammal是基类,而ILandBound和IGrazable是Horse要实现的两个接口。

class Horse:Mammal,ILandBound,IGrazable

{

...

}

12.1.5显式实现接口

为了解决多个接口且指定同名方法的问题,并区分哪个方法实现的是哪个接口,应该显式实现接口。为此,要在实现时指明方法从属于哪个接口,如下所示:

class Horse:ILandBound,IJourney

{

...

int ILandBound.NumberOfLegs()

{

return 4;

}

int IJourney.NumberOfLegs()

{

return 3;

}

}

除了为方法名附加接口名前缀,上述语法还有另一个容易被人忽视的改变:方法没有用public标记。

如果方法是显式接口实现的一部分,就不能为方法指定访问修饰符。这造成另一个有趣的问题。在代码中创建一个Horse变量,两个NumberOfLegs方法都不能通过该变量来调用,因为它们都不可见。两个方法对于Horse类来说是私有的。这个设计是合理的。如果方法能通过Horse类访问,那么以下代码会调用哪一个——ILandBound接口?还是IJourney接口?

是通过恰当的接口来引用Horse对象,如下所示:

Horse horse=new Horse();

...

IJourney journeyHorse=horse;

int legsInJourney=journeyHorse.NumberOfLegs();

ILandBound landBoundHorse=horse;

int legsOnHorse=landBoundHorse.NumberOfLegs();

建议尽量显式实现接口。

12.1.6接口的限制

牢记接口永远不包含任何实现。这意味着以下几点限制。

l 不能在接口中定义任何字段,包括静态字段。字段本质上是类或结构的实现细节。

l 不能在接口中定义任何构造器。构造器也是类或结构的实现细节。

l 不能在接口中定义任何析构器。析构器包含用于析构(销毁)对象实例的语句。

l 不能为任何方法指定访问修饰符。接口所有方法都隐式为公共方法。

l 不能在接口中嵌套任何类型(例如枚举、结构、类或其它接口)。

l 虽然一个接口能从另一个接口继承,但不允许从结构或类继承。结构和类含义实现;如果允许接口从它们继承,就会继承实现。

12.2抽象类

抽象方法

抽象类可以包含抽象方法。抽象方法原则上与虚方法相似,只是它不含方法主体。派生类必须重写(override)这种方法。抽象方法不可以私有。下例将GrazingMammal类中的DigestGrass(消化草)方法定义成抽象方法;草食动物可以使用相同的代码来表示咀嚼草的过程,但它们必须提供自己的DigestGrass方法的实现(即使咀嚼草的过程相同,但消化草的方式不同)。如果一个方法在抽象类中提供默认实现没有意义,但又需要派生类提供该方法的实现,就适合定义成抽象方法。

abstract class GrazingMammal:Mammal,IGrazable

{

public abstract void DigestGrass();

...

}

12.3密封类

如果不想一个类作为基类使用,可以使用C#提供的sealed(密封)关键字防止类被用作基类。例如:

sealed class Horse:GrazingMammal,ILandBound

{

...

}

任何类试图将Horse用作基类都会发生编译时错误。密封类中不能声明任何虚方法,而且抽象类不能密封。

12.3.1密封方法

可用sealed关键字声明非密封类中的一个单独的方法是密封的。这意味着派生类不能重写该方法。只要用override关键字声明的方法才能密封,而且方法要声明为sealed override。可像下面这样理解interface,virtual,override和sealed等关键字:

Interface(接口)引入方法的名称。

Virtual(虚)方法是方法的第一个实现。

Override(重写)方法是方法的另一个实现。

Sealed(密封)是方法的最后一个实现。

13章使用垃圾回收和资源管理

13.1对象的生存期

对象用new操作符创建。下例创建Square(正方形)类的新实例。

Square mySquare=new Square();

new表面上是单步操作,但实际分两步走。

  1. 首先,new操作从堆中分配原始内存。这个阶段无法进行任何干预。
  2. 然后,new操作从原始内存转换成对象;它必须初始化对象。可用构造器控制这一阶段。

注意:C++程序员请注意,C#不允许重载new来控制内存分配。

创建好对象后,可用点操作符(.)访问其成员。例如,Square类提供了Draw方法:

mySquare.Draw();

mySquare变量离开作用域时,它引用的Square对象就没人引用了,所以对象可被销毁,占用的内存可被回收。和对象的创建相似,对象的销毁也分两步走,过程刚好与创建相反。

  1. CLR执行清理工作,可以写一个析构器来加以控制。
  2. CLR将对象占用的内存归还给堆,解除对象内存的分配。对这个阶段你没有控制权。

销毁对象并将内存归还堆的过程成为垃圾回收

注意:C++程序员注意,C#没有提供delete操作符。完全由CLR控制何时销毁对象。

CLR:公共语言运行库和公共语言运行时。它负责资源管理(内存分配和垃圾收集等),并保证应用和底层操作系统之间必要的分离。(详细解释,可以去百度学习,也有专门书籍讲CLR。)

13.1.1编写析构器

使用析构器,可以在对象被垃圾回收时执行必要的清理。CLR能自动清理对象使用的任何托管资源,所以许多时候都不需要自己写析构器。但如果托管资源很大(比如一个多维数组),就可考虑将对该资源的所有引用都设为null,使资源能被立即清理。另外,如果对象引用了非托管资源(无论直接还是间接),析构器就更有用了。

注意:间接的非托管资源其实很常见。文件流、网络连接、数据库连接和Windows操作系统管理的其他资源都是例子。所以,如果方法要打开一个文件,就应考虑添加析构器在对象被销毁时关闭文件。但取决于类中的代码的结构,或许有更好、更及时的办法关闭文件。

析构器的语法是先写一个~符号,再添加类名。例如,下面的类在析构器中打开文件进行读取,在析构器中关闭文件(这只是个例子,不建议总是像这样打开和关闭文件):

class FileProcessor

{

FileStream file=null;

public FileProcessor(string fileName)

{

this.file=File.OpenRead(fileName);//打开文件读取

}

~FileProcessor()

{

this.file.Close();//关闭文件

}

}

析构器存在以下的重要限制:

析构器只适合引用类型。值类型(例如struct)不能声明析构器。

不能为析构器指定访问修饰符(例如public)。这是由于永远不在自己的代码中调用析构器——总是由垃圾回收器(CLR的一部分)帮你调用。

l 析构器不能获取任何参数。这同样是由于永远不由你自己调用析构器。

13.1.2为什么要使用垃圾回收器

只有在对一个对象的所有引用消失之后,才可以销毁该对象,回收其内存以进行重用。

可以看出,对象的生存期管理是相当复杂的一件事情。这正是C#设计者决定禁止由你销毁对象的原因。如果由程序员负责销毁对象,迟早会遇到以下情况之一:

l 忘记销毁对象。这意味着对象的析构器(如果有的话)不会运行,清理工作不会进行,内存不会回收到堆。最终的结果是,内存很快被消耗完。

l 试图销毁活动对象,造成一个或多个变量容纳对已销毁的对象的引用,即所谓的虚悬引用。虚悬引用要么引用未使用的内存,要么引用同一个内存位置的一个完全不相干的对象。无论如何,使用虚悬引用的结果都是不确定的,甚至可能带来安全风险。什么都可能发生。

l 试图多次销毁同一个对象。这可能是、也可能不是灾难性的,具体取决于析构器中的代码怎么写。

必须由垃圾回收器负责销毁对象。垃圾回收器能做出以下几点担保:

l 每个对象都会被销毁,它的析构器会运行。程序终止时,所有未销毁的对象都会被销毁。

l 每个对象只被销毁一次。

l 每个对象只有在它不可达时(不存在对该对象的任何引用)才会被销毁。

13.1.3垃圾回收器的工作原理

它采取的大体步骤如下:

  1. 构造所有可达对象的一个映射。为此,它会反复跟随对象中的引用字段。垃圾回收器会非常小心地构造映射,确保循环引用不会造成无限递归。任何不在映射中的对象肯定不可达。
  2. 检查是否有任何不可达对象包含一个需要运行的析构器(运行析构器的过程成为“终结”)。需终结的任何不可达对象都放到一个成为freachable的特殊队列中。
  3. 回收剩下的不可达对象(即不需要终结的对象)。为此,它会在堆中向下面移动可达的对象,对堆进行“碎片整理”,释放位于堆顶部的内存。一个可达对象被移动之后,会更新该对象的所有引用。
  4. 然后,允许其他线程恢复执行。
  5. 在一个独立的线程中,对需要终结的不可达对象(现在,这些对象在freachable队列中)执行终结操作。

13.2资源管理

有时在析构器中释放资源并不明智。有的资源过于宝贵,用完后应马上释放,而不是等待垃圾回收器在将来某个不确定的时间释放。内存、数据库连接和文件句柄等稀缺资源应尽快释放。这时唯一的选择就是亲自释放资源。这时通过自己写的资源清理方法来实现的。可显式调用类的资源清理方法,从而控制释放资源的时机。

注意:资源清理方法强调的是方法的作用而非名称。可用任何有效C#标识符来命名。

13.2.1资源清理方法

下例使用StreamReader类从文件中读取文本行:

TextReader reader=new StreamReader(filename);

String line;

While((line=reader.ReadLine()) !=null)

{

Console.WriteLine(line);

}

reader.Close();

ReadLine方法将流中的下一行文本读入字符串。如果流中不剩下任何东西,ReadLine方法将返回null。用完reader后,很重要的一点就是调用Close来释放文件句柄以及相关的资源。但这个例子存在一个问题,即它不是异常安全的。如果对ReadLine(或WriteLine)的调用抛出异常,对Close的调用就不会发生。如果经常发生这种情况,最终会耗尽文件句柄资源,无法打开任何更多的文件。

13.2.2异常安全的资源清理

为了确保资源清理方法(例如close)总是得到调用——无论是否发生异常——一个办法是在finally块中调用该方法。下面对前面的例子进行了修改:

TextReader reader=new StreamReader(filename);

try

{

String line;

While((line=reader.ReadLine()) !=null)

{

Console.WriteLine(line);

}

}

finally

{

reader.Close();

}

以上方法可行,但由于它存在几个缺点,所以并不是特别理想。

要释放多个资源,局面很快就会变得难以控制(将获得嵌套的try和finally块)。

有时可能需要修改代码来适应这一惯用法(例如,可能需要修改资源引用的声明顺序,记住将引用初始化为null,并记住查验finally块中的引用不为null)。

l 它不能创建解决方案的一个抽象。这意味着解决方案难以理解,必须在需要这个功能的地方重复代码。

对资源的引用保留在finally块之后的作用域中,这意味着可能不小心使用一个已释放的资源。

using语句就是为了解决所有这些问题而设计的。

13.2.3using语句和IDisposable接口

using语句提供了一个脉络清晰的机制来控制资源的生存期。可以创建一个对象,这个对象会在using语句块结束时销毁。

using语句的语法如下:

using(type variable=initialization)

{

statementBlock

}

下面是确保代码总是在TextReader上调用Close的最佳方式:

using(TextReader reader=new StreamReader(filename))

{

String line;

While((line=reader.ReadLine())!=null)

{

Console.WriteLine(line);

}

}

这个using语句完全等价于以下形式:

{

TextReader reader=new StreamReader(filename);

try

{

String line;

While((line=reader.ReadLine()) !=null)

{

Console.WriteLine(line);

}

}

finally

{

if(reader !=null)

{

((IDisposable)reader).Dispose();

}

}

}

using语句声明的变量的类型必须实现IDisposable接口。IDisposable接口在System命名空间中,只包含一个名为Dispose的方法:

namespace System

{

interface IDisposable

{

void Dispose();

}

}

Dispose方法的作用是清理对象使用的任何资源。StreamReader类正好实现了IDisposable接口,它的Dispose方法会调用Close来关闭流。可将using语句作为一种清晰、异常安全以及可靠的方式来保证一个资源总是被释放。这解决了手动try/finally方案存在的所有问题。

新方案具有以下特点:

l 需要清理多个资源时,具有良好的扩展性。

l 不影响程序代码的逻辑。

l 对问题进行良好的抽象,避免重复性编码。

非常健壮;using语句结束后,就不能使用using语句中声明的变量。

猜你喜欢

转载自www.cnblogs.com/W--Jing/p/9232692.html