购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

第3章
面向对象编程

面向对象编程(Object Oriented Programming,OOP)是一种很重要的程序设计模型,也是一种被广泛应用的编程思想。通俗地讲,面向对象编程认为应用程序是由许多单个的对象组成的,对象自身具有很大的灵活性、封装性和扩展性,既能方便开发者管理代码结构,也比较容易将代码模型和程序的业务逻辑紧密结合。

要理解面向对象编程还得从实战入手,通过编写代码,不断地练习,才能对面向对象编程有更为直观的理解。

C#是一种完全面向对象的编程语言。在C#代码中可以使用的对象有类、结构、枚举、接口和委托。

第3集

3.1 类

类是从客观事物中进行抽象和总结出来的“蓝图”。

第2章中讲述基本数据类型时曾提到过,定义数据类型是为了能够更好地组织和存储数据,这些数据是临时的,只存在内存中,随时可以被清理,变量用于存放与某个类型相关的数据。

既然数据类型要存储数据,那么它内部肯定会包含必要的成员。比如,一个企业内部有多个职能部门(财务部、市场部、人力资源部等),每个部门负责不同的工作,彼此协作,整个企业才能正常运作。因此,类的内部也会定义以下几种不同的成员。

(1)属性。属性用于描述对象的特征。比如对于一个汽车类来说,可以用产品型号、颜色、最大时速等特点来描述,这些都是汽车的属性。

(2)方法。可以把方法比喻为对象的行为。

(3)事件。事件是在特定条件下触发的行为,可以理解为“条件反射”。比如,下课铃响了,学生们就知道放学了。再比如,一个气球内部充满了气体,然后拿一根针去扎它一下,由于遇到被扎这一事件,气球会做出响应——爆裂。

(4)构造器。构造器也叫构造函数、构造方法。它是一种特殊的方法,在创建对象实例时调用,用来进行一些初始化工作。

定义类使用class关键字,下面代码定义了一个表示图书的Book类。

注意: 关键字和类型名称之间要有空格。

3.1.1 字段

字段其实是在类(或结构)内部定义的一种变量,例如

上面代码定义了一个Point结构,它表示一个平面坐标点,其中字段X和Y分别表示横坐标和纵坐标的值。再比如

上面代码定义了一个表示学生信息的Student类,其中包含三个字段:name表示学生姓名,age表示学生年龄,address表示学生的住址。

3.1.2 属性

属性用于描述类的特征,它可以对字段进行封装。通常属性带有get和set访问器,get访问器用来获取属性的值,而set访问器则用来设置属性的值。

再次定义一个Student类,把name、age、address三个字段用属性来封装。代码如下:

以Name属性为例,当获取属性的值时,通过get访问器将name字段的值直接返回;当修改Name属性的值时,通过set访问器把新值传递给value关键字,然后再把value赋值给name字段。另外两个属性情况类似。如果希望让属性只读,即只能获取其值而不允许对其进行赋值,直接去掉set访问器即可,仅保留get访问器。

通过上面的分析可以发现,字段是真正存储数据值的变量,而属性只是一个对外公开的“窗口”,数据通过属性来传递。当获取属性的值时,可以通过return关键字直接把字段中存放的值返回。当要设置属性的值时,调用set访问器把外部传进来的数据存放到value中,再以value作为纽带把数据赋给字段。

上面的示例似乎不足以说明为什么要使用属性。所以,接下来可以把上面的Student类进行如下修改

经过修改后,Name属性不接受空字符串,Age属性不接受小于1或大于100的整数。如果设置的属性值不符合要求,就会抛出异常,即发生错误。上面代码充分展示了属性封装的好处,无论是获取还是设置属性的值,代码都可以事先做出相应的验证和处理,避免属性被设置为意外的值。如果直接把字段暴露给外部的调用代码,则字段很有可能被赋了不满足要求的值,严重时可能会破坏整个类的数据结构。

如果属性值不需要特殊验证处理,可以使用简化的属性声明语法,例如

    public string Name { get; set; }

在编译时,会自动生成存储属性值的字段。由于这种简洁的语法省去了封装私有字段的过程,若希望在声明属性时设置默认值,可以在属性声明后面直接赋值,例如

    public int MyValue { get; set;} = 700;

对于只读属性,只需要在声明时直接忽略set语句,比如

    public string ProductNo { get; }

对于只读属性,还可以使用类似Lambda表达式的形式来声明,比如

    public int MaxTaskNum => 500;

MaxTaskNum属性是只读属性,返回整数值500。当使用“=>”操作符来声明只读属性时,不需要写get语句,也不需要return关键字,“=>”后面直接写上要返回的值即可。

若将set语句改为init语句,表示属性是只读的,并且必须在类型初始化阶段进行赋值,例如

在创建Person类型实例时,必须为Id、Name属性赋值。

3.1.3 方法

方法可以认为是类的行为,通常指的是一个动作。请考虑下面代码

上面代码中使用了属性的快速定义方式。代码定义了一个Music类,表示一段音乐的基本信息。Title和Year是属性,用于描述音乐的特征(标题、发行年份),而Play是方法,因为播放音乐是一种行为,void表示方法不带有返回值。

如果希望方法返回处理结果,可以定义带返回值的方法,如

ReturnInt方法调用后会返回一个整数100。有时需要提供一些数据给方法内部的代码进行处理,这样一来,方法不仅需要返回值,而且还得用上参数,例如下面的Add方法

Add方法的功能是计算两个整数的和,所以它不但要返回计算结果,还需要提供两个参数a和b,以便在调用时可以传递用来进行加法运算的两个操作数。比如,可以这样调用:Add(2, 3),方法执行完成后返回5。

在调用方法时,最常用的方法是依据参数定义的类型和顺序来传递,如上面的Add(2, 3),2传递给参数a,3传递给参数b。那么,如果不想按照参数的声明顺序来传递,又如何处理呢?

方法也很简单,在调用方法时写上参数的名字就可以了,比如

    Add(b:5, a:3)

写上参数的名字,后跟一个冒号(英文),然后再写上要传递的值,如上面的代码,传递给a参数的值是3,而传递给b参数的值是5。

读者还可以在方法中定义可选参数。可选参数顾名思义,就是在调用方法时,可以忽略的参数。正因为如此,可选参数要赋默认值。

在这个方法中,p1是必选参数,p2由于已赋了默认值,就成了可选参数。DoWork方法可以这样调用

    DoWork("123");

因为p2是可选参数,所以以上调用是允许的。但是,如果把DoWork方法改为以下形式,就会出错。

图3-1 错误提示

此时,p1就成了可选参数。如图3-1所示,如果仍然采取上面的调用方式,就会提示错误。即使在代码提示中为p1加上中括号(凡是可选参数,在提示中都会加上中括号),也无济于事。

由此可见,可选参数要放在参数列表的最后才合理,因为如果可选参数放在前面,而代码在调用时又忽略掉,那么编译器就无法让传入的值与参数列表一一对应了。

跟前面属性的声明相似,方法也可以使用Lambda表达式的形式来声明,比如

    public string PickName() => "Jack";

PickName方法没有参数,返回一个字符串实例。

同样,带参数的方法也可以用Lambda表达式来声明。不妨把上面举例的Add方法修改为

    public int Add(int a, int b) => a + b;

在“=>”操作符右侧可以省略return关键字,直接写上a + b的运算结果即可。

3.1.4 构造函数与析构函数

构造函数在类被实例化时(即创建类的对象实例时)调用,它也是类的成员,具有以下特点

(1)构造函数的名称必须与类名相同。

(2)构造函数没有返回值。

(3)默认构造函数没有参数,但也可以定义参数。

即使开发人员不为类编写构造函数,它默认就有一个不带参数的构造函数。考虑以下代码

Car和Car1的定义其实是一样的,如下面代码所示,在使用new运算符创建类的实例时,所产生的结果是相同的。

    Car c1 = new Car();
    Car1 c2 = new Car1();

既然有默认的无参数的构造函数,开发者为什么还要自己去写一个呢?如果希望在类型初始化的过程中加入自己的处理代码,就有必要自己来定义构造函数了。另外,如果要使用带参数的构造函数,就得自己编写了,因为类型默认的构造函数是无参数的。

考虑以下代码

图3-2 默认构造函数无法使用

上面这段代码为Toy类定义了一个带字符串类型参数的构造函数。但是,在创建Toy类的实例时无法再使用无参数的默认构造函数了,如图3-2所示。

这表明,如果自己编写了构造函数,那么默认构造函数就会被覆盖。如果仍然希望用无参数的构造函数,就必须把无参构造函数也一并写上,所以上面代码应该做如下修改

要使用带参数的构造函数来创建类型的实例,就需要传递数据给对应的参数,如

    Toy thetoy = new Toy("小汽车");

对象实例是暂存在内存中的,它不可能永远存在,在不需要使用时就会被清理。在C++中,类实例通过调用构造函数创建,通过调用析构函数来销毁。在C#中也可以为类型编写析构函数,如下面代码所示,为Toy类加上析构函数。

析构函数是以“~”开头的,没有返回值,后紧跟类名,无参数。析构函数只能在类中使用,而且只能有一个析构函数。不能在代码中去调用析构函数,它在资源被销毁时由.NET运行时调用,同时会调用Object类的Finalize方法。前面曾提到过,.NET中的所有类型都是从Object类派生的,因此不管被定义的是类还是结构,又或者是其他数据类型,在这些类型的实例被销毁时都会调用公共基类Object的Finalize方法。但是,由于C#编译器无法直接调用Finalize方法,所以在代码中无须直接重写Finalize方法。

如果存在需要开发者手动进行清理的资源,除了使用析构函数,还有以下替代方案

若要实现IDisposable接口,在Dispose方法中写上自己的处理代码,使用方法如下:

    Speaker sp = new Speaker();
    // 其他代码
    sp.Dispose(); //执行清理

也可以把实现了IDisposable接口的类的实例写到一个using语句块中,当代码执行完成using语句块时会自动调用对象的Dispose方法以释放占用的资源,比如

这里的using语句和引入命名空间的using语句含义不同。这里的using语句是限定一个范围,当代码执行到这个范围的结尾时会自动释放实现了IDisposable接口的对象实例。

using语句还可以省略后面的一对大括号,即

    using (Speaker sp = new Speaker());
    // 处理代码

大括号省略后,当变量sp的生命周期结束时会自动调用Dispose方法。

那么,一个类的实例在创建时真的会调用构造函数,在被销毁时会调用析构函数吗?有没有办法来验证呢?

当然有,而且方法也不复杂,下面就来验证一下上述问题。启动Visual Studio开发环境,新建一个控制台应用程序,然后在Program.cs文件中定义一个Test类,代码如下:

在Test类的构造函数和析构函数中分别使用System.Diagnostics.Debug.WriteLine方法来输出调试信息,这里不使用Console.WriteLine方法来输出是因为当Test类的实例被回收时应用程序已经结束,读者就看不到输出结果了,而使用Debug类输出的内容显示在Visual Studio的“输出”窗口中,程序退出后这些信息还会保留,如果其中包含相关的文本就说明构造函数和析构函数被调用过。

接下来,在Main入口点中加入以下代码,创建Test类的实例。

图3-3 输出调试信息

按下F5键调试运行,很快应用程序就退出了。然后通过菜单栏中的【视图】→【输出】命令打开“输出”窗口,如果代码正确执行了,就会在“输出”窗口中看到如图3-3所示的内容,这也说明Test类的构造函数和析构函数被调用了。

完整的示例代码请参考\第3章\Example_1。

3.1.5 record类型

record类型在声明时使用record关键字,其格式和用法与class(类)相同,区别在于相等比较的计算方法上。

使用class关键字来声明Person类型。

然后实例化两个Person对象,并且它们的属性值相同。

上面代码执行后会输出以下文本

    两个对象描述的不是同一个人

尽管两个对象的属性值相同,但由于它们不是同一个实例,因此相等比较的结果是false(不相等)。这在数据处理方案中会带来许多问题,比如应用程序从客户端接收到一个Person实例,接着从数据库查询并返回另一个Person实例,最后通过相等比较运算来确定这两个Person对象所描述是不是同一个人的信息。根据上面例子的运行结果,身份ID、姓名和年龄都相同的两个对象,从逻辑上看它们描述的都是同一个人的信息,但计算机给出了否定的结果。

如果把Person类型的声明改为record关键字,那么开发人员不需要手动编写代码去比较两个Person对象中各个属性的值是否相同,编译器会自动完成这些工作。

将上文中的代码做如下修改

再次执行程序代码,就会输出逻辑正确的结果了。

3.2 结构

结构与类比较相似,它内部同样可以包含字段、属性、方法等成员。但与类相比,结构有许多限制,比如

(1)结构只能声明带参数的构造函数,不能声明默认构造函数,(不带参数的构造函数),而类是可以的。

(2)结构不能进行继承和派生,但可以实现接口。结构默认是从System.ValueType派生的,而类默认是从System.Object派生的。所以类是引用类型,结构是值类型。

(3)结构在实例化时可以忽略new运算符,而类不可以。

结构使用struct关键字来声明,如

在实例化Pet结构时,可以不用new来创建,如下面代码所示

    // 声明变量,但不需要new来实例化
    Pet pet;
    // 给Pet实例的成员赋值
    pet.Name = "Jack";
    pet.Age = 3;

当然,也可以用new来创建实例

    // 使用new来创建实例
    Pet pet2 = new Pet();
    pet2.Name = "Tom";
    pet2.Age = 2;

需要注意的是,在结构中声明的字段不能进行初始化,如果把上面的Pet结构改为以下形式就会发生错误。

如图3-4所示,因为结构中的字段成员是不能设定初始值的。

如果在结构中定义了属性或者方法等成员,那么也要注意一个问题。举个例子,请考虑下面代码

Book结构定义了两个属性,Name表示书名,ISBN表示图书的ISBN编码,Read方法表示阅读此书的行为。按照前面的做法把Book结构实例化,代码如下:

    Book theBook;
    theBook.Name = "书名";
    theBook.ISBN = "XXX-XX-X-XXXXX";
    theBook.Read();

这时就会发生错误,如图3-5所示,提示未赋值的变量。这告诉我们,在未使用new关键字实例化结构的前提下,只能调用其字段,而属性、方法等成员无法调用。

图3-4 结构中字段不能设定初始值

图3-5 错误提示

于是,把代码进行如下修正

    Book theBook = new Book();
    theBook.Name = "书名";
    theBook.ISBN = "XXX-XX-X-XXXXX";
    theBook.Read();

通过以上例子可以发现,结构的使用有着很多限制,不像类那样灵活。因此,如果要定义属性、方法、事件等成员,应当优先考虑使用类,结构一般用来定义一些比较简单的类型,比如只包含几个公共字段的结构就比较合理,类似于C语言中的结构体。当然,类和结构还有一个更值得关注的区别——类是引用类型,结构是值类型。

3.3 引用类型与值类型

通常,类是引用类型,结构是值类型。那么什么是引用类型,什么是值类型?下面通过两个示例来演示两者的区别。

第一个示例演示的是引用类型(完整的示例代码请参考第3章\Example_2)。首先定义一个Person类,代码如下:

随后,声明两个Person类型的变量,第一个变量创建一个Person实例,接着把第一个变量赋值给第二个变量,代码如下:

    // 创建两个Person实例
    Person ps1 = new Person { Name = "Time", Age = 22 };
    // 把ps1赋值给ps2
    Person ps2 = ps1;
    // 输出ps2的属性值
    Console.WriteLine("ps1被修改前:\nps2.Name : {0}\nps2.Age : {1}", ps2.Name, ps2.Age);
    // 修改ps1的属性值
    ps1.Name = "Jack";
    ps1.Age = 28;
    // 再次输出ps2的属性值
    Console.WriteLine("\nps1被修改后:\nps2.Name : {0}\nps2.Age : {1}", ps2.Name, ps2.Age);

图3-6 两次输出ps2的属性值

第一次输出ps2的属性值时,ps1的属性没有被修改,因此ps2各个属性的值与ps1相同。但之后代码修改了ps1的Name和Age属性的值,我们重点关注这时ps2中各个属性的值会不会跟随ps1一起发生改变。程序运行后输出的内容如图3-6所示。

接下来实现第二个示例,该示例演示值类型(完整的示例代码请参考第3章\Example_3)。同样,先定义一个Person结构(注意是结构,不是类),代码如下:

和第一个示例类似,声明Person结构的两个变量,ps1创建新的实例,然后赋值给ps2,并输出ps2各个属性的值。代码如下:

    // 实例化Person结构
    Person ps1 = new Person { Name = "Bob", Age = 21 };
    // 将ps1赋值给ps2
    Person ps2 = ps1;
    // 输出ps2的属性值
    Console.WriteLine("ps1被修改前:\nps2.Name : {0}\nps2.Age : {1}", ps2.Name, ps2.Age);
    // 修改ps1的属性值
    ps1.Name = "Tom";
    ps1.Age = 33;
    // 再次输出ps2的属性值
    Console.WriteLine("\nps1被修改后:\nps2.Name : {0}\nps2.Age : {1}", ps2.Name, ps2.Age);

图3-7 两次输出ps2的属性值2

程序的输出结果如图3-7所示。

对比以上两个示例的运行结果,在第一个示例中,把ps1赋值给ps2后,修改ps1也会同时改变ps2,因为对于引用类型来说,实例化是在托管堆中动态分配内存的,变量只是保存该实例的地址,即ps1存的只是指向Person实例引用的符号。哪怕是将ps1赋值给ps2,ps2中保存的还是指向那个Person实例的引用,它们所引用的是同一个实例,所以对ps1进行修改其实改变的是它所引用的那个实例,输出的ps2的属性值自然就是更新后的值了。可以用图3-8演示这一过程。

在第二个示例中,由于Person结构是值类型,它所创建的实例不在托管堆中分配内存,而是直接存储在变量中。当ps1赋值给ps2时,就等于把自己复制了一遍,包括其内部成员,即ps1把Name和Age属性的值一同复制到ps2中,就变成两个实例了,因此当代码修改了ps1的属性值后,与ps2并没有直接关系,它们是两个独立的实例。同样也可以用图3-9演示这一过程。

快捷方式是Windows操作系统中特殊的文件类型,其实引用类型与快捷方式很像。引用类型就相当于用户为某个文件创建快捷方式(比如桌面快捷方式)。不管创建了多少个快捷方式,只要指向的是同一个文件,那么当这个文件被修改或者被删除后,会影响到指向该文件的所有快捷方式。

值类型就相当于用户在操作系统中复制文件。比如,把文件A从C盘复制到D盘,然后打开D盘下的A文件进行修改并保存,存放在C盘中的A文件丝毫不受影响,因为这两个A文件是相互独立的。

图3-8 引用类型的实例传递过程示意图

图3-9 引用值类型的实例传递过程示意图

3.4 ref参数与out参数

3.3节中已经对引用类型和值类型进行了比较,因此就会引出一个新的疑问:变量作为参数传给方法,并希望在方法执行完成后,对参数所做的修改能够反映到变量上,这该如何处理呢?

对于引用类型是比较好处理的,因为引用类型的变量保存的是对象实例的地址,直接传递给方法的参数即可。可以用一个示例来验证。

完整的示例代码请参考\第3章\Example_4。首先,定义一个Person类用于测试。

然后,定义一个TestMethod1方法,接收一个Person类型的参数,并在方法中修改它的属性。

最后,声明一个Person类型的变量,并调用TestMethod1方法。

屏幕输出的结果如图3-10所示。在TestMethod1方法调用完成后,ps的Name属性和Age属性将会发生改变。

图3-10 变量ps的属性在调用方法后被修改

下面再定义一个TestMethod2方法。

这次代码不是修改p参数的属性,而是直接创建了一个新的实例,并且为Name属性和Age属性赋了值。代码中采用了一种简便的写法,即在new运算符后用一对大括号直接设定属性的值。比如下面的两个写法都是允许的。

    Person ps = new Person() { Name = "abc", Age = 22 };
    Person ps = new Person { Name = "xyz", Age = 33 };

两种写法的区别不大,就是在new后面调用构造函数时是否带有一对小括号。为什么会允许这两种写法呢?不妨设想一下,如果类的构造函数没有参数(默认构造函数),就不必写上一对小括号;如果类的构造函数带有参数,肯定要传递参数,因此小括号就不能省略了。

接着,调用TestMethod2方法。

图3-11 ps2的属性在调用TestMethod2方法后不变

屏幕的输出结果如图3-11所示。

ps2变量是引用类型的实例,传递到TestMethod2方法后就新建了一个新的实例,而且还给属性赋了值,为什么方法执行完成后ps2的属性值没有改变?ps2在传给TestMethod2方法的参数时它自身被复制到参数p,只不过它复制的是引用地址而不是对象实例本身。也就是说,ps2把它引用的实例的地址复制给了TestMethod2方法的参数p,如果TestMethod2方法的参数p引用了其他对象的实例,那么参数中保存的引用就变了,但是它的改变并不与ps2有直接关系,因为ps2与参数p是两个独立的变量,只不过在进入TestMethod2方法时它们都引用了共同的实例。这就好比用户在操作系统中为文件A创建了快捷方式AA,然后复制快捷方式AA到BB,这时快捷方式BB指向的依然是文件A。但是如果把快捷方式BB改为指向文件B,这时快捷方式AA是不受影响的,它仍然指向文件A。可以使用图3-12和图3-13演示这一过程。

图3-12 ps2传入方法时的示意图

图3-13 方法执行后ps2变量的示意图

要解决以上问题,就要考虑在定义方法的参数时加上ref或out关键字,这两个关键字比较相似,ref参数在传入前必须先初始化,而out参数不需要事先进行初始化,只要在传入前声明变量即可。我们可以用ref和out关键字来解决上面的问题。

上述两个方法的实现方式是一样的,只是TestMethod3方法使用ref关键字来修饰参数p,TestMethod4方法则使用out关键字来修饰参数p。

随后分别调用这两个方法。

图3-14 ref和out参数调用结果

由于out参数允许传递未初始化的变量,所以在调用TestMethod4方法时,ps4没有进行初始化,即为null(空引用),然后传递给out参数,在TestMethod4方法中为其赋值。程序的输出结果如图3-14所示。

不管是使用ref关键字还是out关键字来修饰参数,在调用方法时都要带上相应的关键字,比如上面的TestMethod3(ref ps3)和TestMethod4(out ps4)。

用ref或out关键字来修饰方法参数还能解决值类型变量的传递问题。值类型变量之间的赋值是把自身进行一次复制,因此把值类型的变量传递给方法的参数后,就把自身复制到参数中。而在方法中对参数的修改不会影响到方法外部的变量,因为它们是相互独立的。为了让值类型的变量也能按引用传递,以达到修改外部变量的目的,可以在方法的相应参数上加上ref或out关键字。

同样,也可以用一个示例来演示(完整的示例代码请参考\第3章\Example_5)。首先定义用来测试的Dress结构。

Dress结构包含一个string类型的Color字段和一个double类型的Size字段。接下来定义三个方法。

F1方法的参数d不带任何修饰符,F2方法的参数d用ref关键字来修饰,F3方法的参数d则使用out关键字来修饰。随后,代码分别调用这三个方法,注意观察和比较屏幕上输出的信息。

F1方法的参数不带任何修饰,因此调用时是按值传递的,故在F1方法内部对参数d的修改不影响变量d1,输出结果如图3-15所示。

F2方法的参数加了ref关键字,因此调用时是按引用传递的,所以在F2方法内部修改d参数会影响变量d2,输出结果如图3-16所示。

F3方法使用了out关键字来修饰参数d(输出参数),所以在调用方法后会给变量d3赋值,输出结果如图3-17所示。

图3-15 按值传递不影响变量d1

图3-16 按ref传参后会影响变量d2

图3-17 修改out参数影响变量d3

3.5 方法重载

所谓方法重载,就是在类型内部存在名字相同但签名不同的方法。以下情况可以构成重载。

(1)具有不同类型的返回值且参数有差异的同名方法可以重载。例如:

    public string DoWork(int n);
    public int DoWork();

两个方法都命名为DoWork,第一个DoWork方法的返回类型是string,并带有一个int类型的参数,而第二个DoWork方法则返回int类型,没有参数,所以它们可以重载。但是,参数列表相同,只是返回值类型不同的两个同名方法是不能构成重载的。

(2)参数列表的类型及顺序不同,可以构成重载。例如:

    void OnTest(string a) { }
    void OnTest(float b, double a) { }

第一个OnTest方法只有一个参数,类型为string;第二个OnTest方法有两个参数,分别是float类型和double类型,因此它们可以构成重载。但是下面的方法声明不能与上面的OnTest构成重载

    void OnTest(float a, double b) { }

虽然参数的名字不同,但是参数的类型和顺序相同,不能构成重载。编译器只关注参数的个数、类型和顺序,而参数的名字并不编译。比如:

    void Send(int a, int b) { }
    void Send(string a, string b) { }

上面两个Send方法虽然都有名为a、b的两个参数,但是它们的类型不同,前者是int类型,后者是string类型,故构成重载。

(3)带ref或out修饰符的参数。如果一个方法的参数带有ref关键字修饰的参数,而另一个同名方法不带有ref关键字修饰的参数,那么这两个方法构成重载。例如:

    void Compute(ref short v) { }
    void Compute(short v) { }

同理,如果一个方法的参数使用out关键字修饰,而另一个与之同名的方法的参数不使用out关键字修饰,也能构成重载,如下面两个方法

    void Compute(short v) { }
    void Compute(out short v) { v = 2; }

由于编译器不区分ref和out参数,所以如果一个方法使用ref参数,而另一个方法使用out参数,而且参数的个数、类型和顺序相同,则不能构成重载。

以上两个方法名称相同,参数个数和类型相同,第一个Compute方法使用了ref参数,而第二个Compute方法使用了out参数,因此这两个方法不能重载,在编译时会报错。

构造函数也是一种特殊的方法,因而也支持重载,前面在讲述类的定义时,已经介绍过构造函数的重载了,即为类型定义多个构造函数。比如下面的Goods类

在Goods类中,定义了两个构造函数,第一个是默认构造函数,第二个构造函数是一个重载,带有一个string类型的参数。

3.6 静态类与静态成员

static关键字既可以修饰类型,也可以修饰类型中的成员。使用了static关键字即表示声明为静态类型或静态成员。静态是相对于动态而言的,前面曾讲述过变量和常量,变量是动态声明的,而且可以动态地进行赋值。也就是说,变量是在使用时才去分配内存,即对象的实例;而静态类型或静态成员正好相反,它们不是基于实例的,所以在使用前不需要实例化,它们是基于类型本身的,直接就可以调用静态成员。

下面代码定义了一个DataOperator类,内部定义了两个静态方法——AddNew和UpdateNow。

在调用时,无须声明DataOperator类型的变量,也不需要进行实例化,而是直接调用它的公共方法即可。比如:

    DataOperator.AddNew();
    DataOperator.UpdateNow();

其实这个也很好掌握,直接写上类型的名字,然后用点号(成员运算符)来访问其公共成员即可。static关键字不仅能修饰方法,而且可以用来修饰字段、属性、事件等成员。例如下面代码定义了两个静态属性。

同理,向这两个属性赋值前不用声明变量,也不用实例化,直接访问类型的成员即可。

    Car.CarName = "高档汽车";
    Car.Speed = 170d;

如果将static关键字用于修饰类型,表明整个类型都是静态。在这种条件下,类型只能定义静态成员。比如,下面代码在编译时就会报错。

Test已声明为静态类,因此它只能定义静态成员,以上代码中的SayHello方法和Message属性是不能通过编译的。正确的代码如下:

3.7 只读字段

在声明字段成员时,如果加上readonly关键字,此字段就会变成只读。例如:

Test类实例化之后无法修改FixLabel字段,因此下面代码在编译时会报错。

    Test n = new Test();
    n.FixLabel = "somework";

只读字段可以在声明时赋值。

    public readonly string FixLabel = "something";

或者在构造函数里面进行赋值。

结构类型也可以定义只读字段。下面例子中,D1字段为只读。

如果在声明结构类型时加上readonly关键字,那么结构类型内部所有字段都必须声明为只读。

3.8 可访问性与继承性

前面介绍了类和结构的定义,尤其是类,因为它的应用更为广泛,因此本书随后会把与类有关的内容作为重点讲述对象。类的定义可以体现面向对象编程中的封装性,本节将会介绍可访问性与继承性。

3.8.1 可访问性

清楚各种可访问性的限制,可以更好地保护和管理自己编写的类型。常用的可访问修饰符可以参考表3-1。

表3-1 可访问性修饰符

许多初学者会认为有关可访问性的内容不好记忆,其实可访问性并不需要去记忆,只要多动手去写一下代码就能够掌握,在学习过程中,应该学会自己编写代码去验证问题。

启动Visual Studio开发环境,然后按照以下步骤练习。

(1)按快捷键Ctrl + Shift + N,打开“新建项目”窗口,从项目模板列表中选择“控制台应用程序”,输入项目名、解决方案名及存放路径,最后单击“确定”按钮完成项目的创建。

(2)在Program类所在的命名空间下声明一个A类。

(3)在Main方法中创建A类的实例,并尝试访问Value属性。

这时会得到Value属性不可访问的提示,因为它被定义为private,只能在类的内部使用。

(4)定义一个B类,同样也定义一个Value属性,但访问修饰符为public。

(5)在Main方法中实例化一个B对象,并向其Value属性赋值。

由于B类的Value属性定义为public,即公共属性,没有访问限制,故在类的外部可以访问。

(6)internal关键字只允许同一程序集内的代码访问。通常情况下,在使用Visual Studio进行开发时,一个项目就是一个程序集。所以,接下来可以在当前解决方案中再新建一个项目。打开“解决方案资源管理器”窗口,在解决方案节点上右击,并从弹出的快捷菜单中选择【添加】→【新建项目】,在打开的“新建项目”窗口中选择“类库”,然后输入项目的名字,单击“确定”按钮完成。

(7)在新建的项目中用internal关键字声明一个M类。

(8)按Ctrl + S快捷键保存,然后在“解决方案资源管理器”窗口中,在控制台应用程序项目的“引用”节点上右击,从弹出的快捷菜单中选择【添加引用】。

(9)在“引用管理器”窗口左侧导航到“解决方案”→“项目”,然后在窗口的中间区域选中新建的类库项目,然后单击“确定”按钮,如图3-18所示。

图3-18 添加引用

(10)在Main方法中尝试把类库项目中的M类进行实例化。

这时会出现错误提示,因为M类声明为internal,只能在它所在程序集中使用,在其他程序集中无法访问。当然,把M类改为public就可以访问了,public是不受限制的。另外,由于在命名空间下类的默认访问方式为internal,所以在命名空间下直接定义类可以省略internal关键字。

完整的示例代码请参考\第3章\Example_6。

3.8.2 继承性

因为结构是不能派生的,所以继承是对类而言的。

在对客观事物进行抽象提取过程中,人们需要根据客观事物的发展特点做出有层次性的描述。例如,星球是对宇宙中各类星体的总概括,具体划分起来,可能会有恒星、行星等,再往下分就会有木星、土星、地球等。然而对于星球这个类来说,它可以囊括所有星体的一些共同特征,比如自转速度、公转速度、直径等。所以,类与类之间可以存在一种层次关系,这种关系就类似于“父子”关系。例如,衣服是一个基类,它可以用尺寸、颜色、布料等特点来描述,但是衣服是可以分为很多种,于是可以从衣服类派生出其他衣服类,如毛衣、运动裤、裙子等。这些类不仅继承了衣服类中定义的属性或其他成员,而且它们可以根据自身的特点来扩展一些新的成员。

下面通过一个示例实现类的继承。在Visual Studio开发环境中新建一个控制台应用程序项目。首先,定义一个Person类,它具有Name、Address、Age三个属性。

其次,定义一个表示学员信息的Student类,从Person类派生,并增加一个新的属性Course,表示学员学习的课程。

最后,在代码中创建一个Student的实例,并向其属性赋值,然后输出到屏幕上。

图3-19 屏幕输出结果

Student实例的成员除新增的Course属性外,还保留了Person类所定义的几个属性。这说明,派生类(子类)在基类(父类)的基础上进行了扩展,其实就是“子承父业”。屏幕输出结果如图3-19所示。

既然派生类是基类的扩展,那么在创建派生类的实例时,调用的是派生类的构造函数还是基类的构造函数?如果都被调用,那么谁先谁后呢?要回答这个问题并不难,只需要修改一下Person类和Student类定义的代码,分别为它们加上构造函数和析构函数,并使用Debug类输出调试信息。

图3-20 输出的调试信息

运行程序,然后按键盘上的任意一个键将其关闭。打开“输出”窗口(可以在快速启动搜索框中输入“输出”来查找),会看到输出的调试信息,如图3-20所示。

在实例化Student类时,先调用基类的构造函数,再调用派生类的构造函数;在实例被释放时,析构函数的调用顺序与构造函数的调用正好相反。

完整的示例代码请参考\第3章\Example_7。

3.8.3 注意可访问性要一致

派生类的可访问性要与基类保持一致,至少派生类的可访问性不要比基类高。请考虑以下代码

乍一看这段代码并没有问题,B类自身定义了一个Value属性,并从A类继承了一个OnTest方法,然而当将上述代码进行编译时,就会发现错误。

导致错误的原因是基类和子类的可访问性不一致。那么为什么要求派生类的可访问性要跟基类一致呢?A类的可访问性是定义为internal的,即只能在当前程序集中访问,而B类从A类派生,并且B类的可访问性是public的,访问不受限制。当A类和B类放在同一个程序集中时似乎不成问题,但是如果要从另一个程序集来访问B类,由于B类是公共的,自然可以创建B类的实例,只是OnTest方法是从A类继承过来的,A类又声明为internal,这就使得OnTest方法无法被访问,显然就出现访问冲突了。

因此,为了保证从基类继承下来的成员都能被有效访问,派生类的可访问性不应该比基类高。派生类的可访问性可以比基类低,因为这样不影响对基类成员的访问,所以如果A类声明为public,B类从A类派生,并声明为internal,是没有问题的,因为它保证了基类的公共成员可以被有效访问。

3.8.4 隐藏基类的成员

下面一段代码中,A类定义了一个Play方法,可访问性为private,即私有方法;B类从A类派生,也定义了一个Play方法,可访问性为public。

如果实例化和调用B类的Play方法,屏幕上输出的是B,因为A类中的Play方法不会被调用。A类中的Play方法是私有(private)方法,只能在A类内部访问,派生类也无法访问。

把代码改为

A类和B类的Play方法被定义为public,那么,B类的实例到底会调用哪个Play方法呢?仍然是选择了B类的Play方法。这里涉及隐藏基类成员的问题。上面的代码在B类中定义了一个和A类中相同的Play方法,这样就会把A类中的Play方法给隐藏了,因此在B类中就不再调用A类中的Play方法了。

虽然这样做在编译时不会报错,但会收到警告。这种警告提醒开发者是否不小心隐藏了基类的成员。有时候代码量较大,开发者在编写代码时,可能忘了在A类中已经写过了Play方法,于是在编写B类时,又重复定义了Play方法,所以编译器才会发出警告。

如果确实要隐藏基类的成员,就应该明确地告诉编译器,方法是在方法的声明上加一个new关键字。这个new和用来实例化对象时用的new操作符是同一个单词,但在这里它的含义不是创建实例,而是隐藏基类的成员。所以上面的代码可以改为

3.8.5 覆写基类成员

第4集

在编写派生类时,根据当前类的具体需要,可能要用相同的成员来覆盖或者扩展基类的成员。3.8.4节已经介绍了使用new关键字来隐藏基类的成员,但会引发一个问题。下面用一个例子就可以说明问题

F类定义了一个属性ThisName,返回字符串F;G类从F类派生,也定义了一个ThisName属性,并且隐藏了F类的ThisName属性。

接着创建一个G类的实例,并输出其ThisName属性的值。

    F g = new G();
    Console.WriteLine(g.ThisName);

最后输出的字符串是F,而不是G。因为变量g被声明为F类型,但是赋值时引用了G类的实例。这里做了一次隐式转换,是允许的,派生类的实例可以赋值给用基类类型声明的变量,后面在讲述类型转换时还会提到。在这个例子中,预期的结果是输出字符串G,但是因为变量g被声明为F类型,尽管它引用了派生类G的实例,变量仍会选择调用基类F的成员。

要解决上述问题,就要使用virtual和override关键字,方法是将基类中需要被覆写的成员加上virtual关键字使其“虚化”,接着把派生类中覆写的成员加上override关键字。

接下来将通过一个实例来演示处理过程(完整的示例代码请参考\第3章\Example_8)。下面代码声明了D类,包含一个公共的Work方法,定义为虚方法(virtual);E类从D类派生,用override覆写了D类的Work方法。

分别以D类来声明两个变量,变量d引用D类的实例,变量e引用E类的实例。之后分别用这两个变量调用Work方法。

虽然变量d、e都以D类来声明,但由于virtual和override关键字协同实现了类型的多态性,运行时库会根据变量所引用的实例类型来判断应该调用谁的Work方法。预期的结果也达到了,输出结果如图3-21所示。

图3-21 两个Work方法的调用结果

override不仅能覆写基类的成员,它还能实现对基类成员的扩展,因为在使用override关键字覆写基类成员的同时,也可以使用base关键字来调用基类的成员。base关键字与this关键字是相对的,this关键字引用的是当前类的实例,而base关键字引用的是基类的实例。

下面继续完成示例。定义一个X类,包含一个virtual的Output方法,再定义一个Y类,从X类派生,并且覆写X类的Output方法,同时也调用基类的Output方法。

然后使用以下代码进行测试。

    X y = new Y();
    y.Output();

由于在Y类的Output方法中加了“base.Output();”,因此基类的Output方法也被调用。最终得到如图3-22所示的结果。

图3-22 扩展基类成员的输出结果

3.8.4节中提到的隐藏基类成员及本节所讲述的成员覆写,构成了面向对象编程中的多态性,应用程序在运行过程中会根据类的继承状态自动识别出应该调用哪些成员。

3.8.6 阻止类被继承

有时开发者并不希望自己写的类被继承,可以在定义类时加上sealed关键字。用sealed关键字声明的类也叫密封类。比如下面代码

    public sealed class Room { }

Room被定义为密封类,因此就无法从Room类派生。

如果只是想阻止虚成员被后续的派生类覆写,而并不打算阻止整个类被继承,那么方法与密封类相同,在覆写虚成员时加上sealed关键字即可。请考虑下面一段代码

A类中定义了虚方法Run,B类继承A类,并覆写Run方法,同时使用sealed关键字,使得从B类派生的类不能再覆写Run方法。将成员声明为protected,只允许当前类和派生类访问,其他外部对象无法访问。

3.9 抽象类

第5集

人们通常把抽象类视为公共基类。抽象类最明显的特征是不能实例化,所以通常抽象类中定义抽象成员,即不提供实现代码。请考虑下面代码,T类是一个抽象类,它包含一个抽象方法Check。

通过上面的代码,我们会发现:①使用abstract关键字表示类或成员是抽象的;②抽象方法因为不提供具体的实现,所以没有方法体(一对大括号所包裹的内容),语句以分号结束。抽象类仅对成员进行声明,但不提供实现代码,就等于设计了一个“空架子”,描绘一幅大致的蓝图,具体如何实现取决于派生类。正因为抽象类自身不提供实现,所以不能进行实例化,调用没有实现代码的实例没有实际意义。

在Visual Studio开发环境中新建一个控制台应用程序项目(完整的示例代码请参考\第3章\Example_9),先定义一个表示所有球类的基类Ball,该类为抽象类。

CateName属性返回某种球类的名称,如果是足球就返回“足球”,如果是排球就返回“排球”。Play方法会根据不同的派生类提供不同的实现,如果是足球,就输出“正在踢足球……”。

图3-23 自动完成抽象类的实现

接下来分别用FootBall类和BasketBall类来实现抽象类Ball。读者可以使用Visual Studio代码编辑器提供的辅助功能来自动完成抽象类实现。操作方法是,当输入抽象类的名称后,在抽象类的类名下方会显示一个智能标记,单击该标记,从下拉菜单中选择【实现抽象类“类名”】,如图3-23所示。

生成代码后,实现抽象类的抽象成员也使用override关键字,与前面提到的成员覆写相似,实现抽象类,也可以看作对基类成员进行覆写。对生成的代码进行修改,最终的实现代码如下:

下面在项目模板自动生成的Program类中声明一个PlayBall方法,代码如下:

PlayBall方法可以体现抽象类的用途,参数ball只声明为Ball类型,即定义的抽象类。这样的好处在于,不管调用方传递进来的是什么类型的对象,只要是实现了Ball抽象类的类型即可。抽象类Ball已经规范了派生类肯定存在CateName属性和Play方法两个成员。显然这种处理方式比较灵活。

最后在Main入口点方法中进行调用测试。

    FootBall football = new FootBall();
    BasketBall basketball = new BasketBall();
    // 调用PlayBall方法
    PlayBall(football);
    PlayBall(basketball);

图3-24 屏幕输出结果

输出结果如图3-24所示。

另外,需要注意的是,在抽象类中是可以定义实现代码的,即非抽象成员。不妨把上面的Ball类修改一下,增加一个Radius属性,该属性提供了具体的实现。

但是,在非抽象类中不能声明抽象成员。这一点其实很好理解,因为非抽象类是可以用new来实例化的,如果存在未实现的抽象成员,代码在调用实例成员时就没有意义了。反过来,在抽象类中定义非抽象成员是允许的,因为抽象类不能用new来实例化,而实现抽象类的派生类会继承这些成员,所以代码在调用时,访问的必定是派生类的成员,不会出现没有意义的代码。

3.10 接口

第6集

接口为后续的代码编写与程序开发制定了一个“协定”,也就是一个规范。不管代码由谁来编写,都以预先设计好的接口为基准,对项目的后续扩展起到一定的约束作用。接口就好比每个国家都会制定一部宪法,然后其他的法律条文都以宪法为底本来进行补充和深化。

提到接口,很容易让人想到抽象类,因为接口在形式和使用方法上与抽象类很类似。因此,不妨根据抽象类的特点来猜测接口可能具备的特点

(1)不能被实例化。

(2)自身不提供实现代码。

第(1)项特点对于抽象类和接口来说是相同的;第(2)项特点对于抽象类与接口并不完全相同,可以通过以下两段代码很好地区分出来。

在上面两段代码中,左侧是抽象类的定义,右侧是接口的定义。仔细观察会找到以下几点差异:

(1)抽象类的成员定义是带有可访问性关键字的(如public),而接口是不带可访问性关键字的,因为接口中所声明的成员都是公共的,所以没有必要添加访问修饰符。

(2)抽象类除了包含抽象成员,还可以包含非抽象成员,也包含构造函数,而接口不能包含具备实现代码的成员,也不能包含构造函数。因为接口是另一种类型,不是类,而抽象类也是类的一种。

通过上面罗列的几个特点可以知道,接口与抽象类确实有着一些共同点。不过,接口的特点并不只是这些。

3.10.1 定义接口

定义接口使用interface关键字,如下面代码所示,定义了一个IBook接口。

    public interface IBook { …… }

在命名接口时,根据习惯,会在前面加上一个大写字母“I”。并不是说一定要这样做,这仅仅是一种习惯,.NET类库中许多接口都是以“I”开头的(取interface的首字母),这种命名方式的好处是便于识别。很多时候,开发者设计接口主要起到规范作用。而后面实现接口的代码并不一定由同一位开发者来编写,有可能会交给别人来实现。如果能做到对接口进行合理命名,那么实现代码的人就可以很方便地识别出接口类型。虽然在Visual Studio中会以不同的图标来显示不同类型,但是在编写接口时最好能够按照习惯去命名,没有必要去破坏这个习惯。

在默认情况下,接口和类一样,将声明为允许内部访问(internal),即只能在同一程序集中访问,因此如果要让接口对外公开,应当加上public修饰符。

3.10.2 接口与多继承

在C++语言中允许多继承,即一个类可以同时继承多个基类。而在C#语言中,只允许单继承,即一个类只能从一个类派生,允许多层次派生,但一个子类不能同时继承多个基类。在C#语言中,以下定义是错误的。

C类可以从A类派生,或者从B类派生,但不能同时派生自A、B两个类。

但是,一个类是可以实现多个接口,这在形式上达到了多继承的效果。比如下面代码

    public interface IA { }
    public interface IB { }
    public class C : IA, IB { }

在上面的代码中,C类同时实现了IA、IB两个接口。多个接口间用逗号(英文)分隔。

3.10.3 实现接口

由于接口中定义的成员都是公共成员,因此在实现接口时,无论是结构(结构可以实现接口)还是类,都必须以公共成员来实现接口的成员,而且必须实现接口的所有成员。请考虑下面代码

在上述代码中,IX定义了一个属性和一个方法。Z类的错误是没有将Num属性声明为public,因为接口中定义的都是公共成员,因此在实现接口时也要将成员声明为公共成员。对于属性中的get和set访问器没有严格的要求,不要求与接口中定义的一致。

Y类的错误是没有完全实现IX接口的成员,只实现了Num属性,Work方法没有实现。

接口也可以继承接口,如果B接口继承了A接口,那么B接口也包含A接口中定义的成员,这一点与类的继承相似。请看下面的代码

DoA方法是在IA接口中定义的,Name属性是在IB接口中定义的。Test类实现IB接口,而IB接口继承IA接口,因此IB接口包括了Name属性和DoA方法。故Test类实际上是实现了两个成员——Name属性和DoA方法。

3.10.4 显式实现接口

显式实现接口是为了解决接口成员冲突的问题,这种冲突主要表现在成员名称上会出现重复。请考虑下面代码

由于IA接口和IB接口所定义的成员相同,Test类在实现IA和IB两个接口时,也只能实现一个Speak方法。那么,IA和IB接口不就一样吗?这样声明是否还有意义呢?如果仅从代码层面上说,确实没有实际意义。不过,有时需要定义不同的接口,但是不能排除多个接口之间会定义重复的成员。比如上面的例子,假设两个接口的Speak方法代表的是不同含义或不同用途的成员,开发人员希望两个Speak方法实现不同的功能,当然也可以用不同的类分别实现IA和IB接口。可是,如果确实需要用一个类同时实现两个接口,而又要保证两个Speak方法都能实现,有效的解决方法是使用显式实现接口。

所谓显式实现接口,就是在类中实现的接口成员前面加上接口的名字,以说明该成员来自哪个接口。下面通过一个实例来演示如何显式实现接口。

在Visual Studio开发环境中新建一个控制台应用程序项目。定义两个接口ITest1和ITest2,它们都有一个同名方法Run。

图3-25 显式实现接口

接着,定义一个Test类,显式实现这两个接口,可以使用Visual Studio的自动生成代码的功能来完成。如图3-25所示,在写完Test类实现ITest1和ITest2接口的代码后,在ITest1和ITest2下方都会出现一个智能标记,分别单击智能标记,并从菜单中选择【显式实现接口】命令。

实现接口的代码如下:

显式实现接口的成员,在名字前面要加上所属接口的名字及一个成员运算符(.)。

接下来介绍如何使用Test类。显式实现了接口的成员无法通过类的实例来调用,只能通过对应的接口来调用。也就是说,Test类的实例无法调用Run方法。那么能否通过隐式转换的方法来实现调用呢?既然Test类实现了ITest1和ITest2接口,那么分别声明ITest1和ITest2类型的变量,再赋以Test类的实例,就可以调用了,代码如下:

    ITest1 t1 = new Test();
    ITest2 t2 = new Test();
    t1.Run();
    t2.Run();

这样确实可以分别调用Run方法,但是存在一个问题:上面代码中其实创建了两个Test实例,这两个实例是相互独立的,如果两个Run方法之间涉及类中的一些数据,比如私有字段等,显然两个Run方法不是调用自同一个实例,这样会造成数据的不统一。

因此,正确的调用方法是先创建Test类的实例,然后把这个实例分别转换为对应的接口类型来调用特定的Run方法。具体代码如下:

    Test t = new Test();
    // 调用ITest1.Run方法
    ((ITest1)t).Run();
    // 调用ITest2.Run方法
    ((ITest2)t).Run();

图3-26 输出结果

最后输出的内容如图3-26所示。

完整的示例代码请参考\第3章\Example_10。

3.11 扩展方法

第7集

扩展方法是一种比较有趣的方法,它可以在不继承现有类型的前提下扩展类型。扩展方法可以合并到要扩展类型的实例上。因此,扩展方法要定义为静态方法,并且第一个参数必须为要扩展类型的当前实例(参数前面要加上this关键字)。

扩展方法使用起来并不复杂,在定义扩展方法时,必须先定义一个静态类。然后将扩展方法包含在该类中就可以了。

下面的示例将扩展.NET类库中现有的System.String类,把字符串中的各个字符用两个空格来分隔,比如字符串为“jack”,调用扩展方法后就变为“j a c k”。

首先定义扩展方法,代码如下:

其次,通过String类的实例来调用该扩展方法。

图3-27 输出的每个字符间都带有空格

最后,得到如图3-27所示的结果。

完整的示例代码请参考\第3章\Example_11。

3.12 委托与事件

委托是一种在形式上与方法签名相似的类型。委托实例化后可以与方法关联,在调用委托实例的同时会调用与之关联的方法。这使得代码可以把方法当作参数来传递给其他方法。从运行方式来看,委托与C语言中的函数指针有些类似。

第8集

3.12.1 定义和使用委托

委托的声明和方法的声明相似,不过要使用delegate关键字,以告诉编译器这是委托类型。由于它是一种类型,因此是可以独立声明的。

使用委托时要先实例化,和类一样,使用new关键字来产生委托的新实例,然后将一个或多个与委托签名匹配的方法与委托实例关联。随后调用委托时,就会调用所有与该委托实例关联的方法。与委托关联的可以是任何类或结构中的方法,也可以是静态方法,只要是可以访问的方法都可以。

例如,下面代码定义了一个DoSome委托

    public delegate void DoSome(string msg);

分析哪些方法可以与DoSome委托匹配。该委托能匹配的方法必须返回void类型(不返回任何内容),而且接受一个string类型的参数。不妨看几个例子,下面的TestDo方法是匹配的。

    static void TestDo(string str) { }

下面的Test2方法就不能匹配,因为它的参数不是string类型。

    public void Test2(int n) { }

下面的WorkAs方法也不匹配,因为它没有参数,而且有返回值。

    public float WorkAs() { }

再看下面的ToDone方法,虽然它返回void,参数也是string类型,但是它有两个参数,而DoSome委托只有一个参数,所以也不匹配。

    private void ToDone(string str1, string str2) { }

任何数据类型都会隐含有公共基类,因为任何类型都是从System.Object类派生的。而委托类型隐含的公共基类是System.Delegate或System.MulticastDelegate类,后者实现了委托的多路广播,即一个委托类型的实例可以与多个方法关联。在实际使用中,.NET框架所支持的每种编程语言都会实现与委托类型相关的关键字,在C#语言中为delegate。编译器会自动完成从System.Delegate或者System.MulticastDelegate类的隐式继承,开发者不能自己编写代码来继承这些类型,只能在代码中使用delegate关键字来声明委托类型,剩下的工作将由编译器来完成。

在实例化委托时,可以将要关联的方法作为参数来传递,例如使用上面例中的DoSome委托跟与之匹配的TestDo方法进行关联,就可以这样来实例化

    DoSome d = new DoSome(TestDo);

还可以使用更简洁的方法来实例化委托,即直接把与委托匹配的方法赋值给委托类型的变量。

    DoSome d = TestDo;

调用委托与调用普通方法相似,有参数就传递参数,有返回值就接收返回值,例如:

    d("abc");

委托之间可以进行相加和相减运算,但这与数学中的加减运算不同,委托的加运算可以增加所关联的方法,而减法则是从委托所关联的方法列表中移除指定的方法,例如:

    d += new DoSome(TestRun);

在Visual Studio开发环境中新建一个控制台应用程序项目(完整的示例代码请参考\第3章\Example_12)。

在项目生成的Program类中定义三个静态方法,这三个方法必须签名相同,以便稍后使用。

三个方法都带一个string类型的参数,并且在方法体中会向屏幕输出相关文本和参数。

定义委托类型。

    public delegate void MyDelegate(string s);

创建三个MyDelegate实例,分别与上面三个方法关联,并逐个进行调用。

    // 定义三个委托变量
    MyDelegate d1, d2, d3;
    // d1关联TestMethod1方法
    d1 = TestMethod1;
    // d2关联TestMethod2方法
    d2 = TestMethod2;
    // d3关联TestMethod3方法
    d3 = TestMethod3;
    // 分别调用三个委托实例
    Console.WriteLine("分别调用三个委托实例,输出结果如下:");
    d1("d1");
    d2("d2");
    d3("d3");

屏幕输出结果如图3-28所示。

再创建一个MyDelegate委托实例d4,并且与三个方法关联,在调用d4时就能同时调用这三个方法。

    // 先与TestMethod1方法关联
    MyDelegate d4 = TestMethod1;
    // 再与TestMethod2和TestMethod3方法关联
    d4 += TestMethod2;
    d4 += TestMethod3;
    // 调用d4
    Console.WriteLine("\n调用d4可同时调用三个方法,结果如下:");
    d4("d4");

d4在实例化时与TestMethod1方法进行了关联,而后通过相加运算又与TestMethod2和TestMethod3方法进行关联。也就是说,d4是多播委托,它同时与多个方法关联,调用该委托实例可同时调用多个方法,输出结果如图3-29所示。

将TestMethod2方法从d4关联的方法列表中移除,并再次调用d4。

    // 从d4关联的方法列表中移除TestMethod2方法
    d4 -= TestMethod2;
    // 再次调用d4
    Console.WriteLine("\n移除与TestMethod2方法关联后:");
    d4("d4");

结果如图3-30所示,可以看到TestMethod2方法就不再被调用了。

图3-28 调用三个委托实例的输出结果

图3-29 同时调用多个方法

图3-30 TestMethod2方法移除后d4的调用结果

3.12.2 将方法作为参数传递

第9集

委托可以让方法作为参数传递给其他方法。下面用一个示例阐述这一问题。完整的示例代码请参考\第3章\Example_13。定义一个委托类型,代码如下:

    public delegate void MyDelegate();

在项目生成的Program类中定义两个方法M1和M2,因为本例稍后会顺便验证委托的传值方式。

    static void M1() { Console.WriteLine("方法一"); }
    static void M2() { Console.WriteLine("方法二"); }

再定义一个Test方法

在方法体中调用委托,随后将参数d与M2方法关联。进行测试调用

    MyDelegate de = M1;
    Test(de);
    // 执行Test方法后重新调用委托
    de();

图3-31 输出结果

声明委托变量de并与M1方法关联,然后调用Test方法,在调用Test方法后再调用一次委托变量de。最终得到如图3-31所示的结果。

在Test方法中代码修改了参数d,与M2方法进行了关联,但是当方法执行完成后,在方法外再次调用de,输出的仍然是“方法一”。因此,本示例不仅演示了如何通过委托实现将方法作为参数传递,同时也说明了委托类型在传递时是进行自我复制的。参数d在方法内部被修改,但不影响方法外部的de变量。虽然委托是引用类型,但是在方法内部让委托变量与M2方法进行了关联,就等于参数d引用了新的委托实例。而外部的委托变量传递给参数d时,只是把委托实例的地址进行了复制,所以方法调用完成后,外部的变量所引用的仍然是原来的委托实例。

3.12.3 使用事件

事件与委托有着密切的关系,因为事件自身就是委托类型。由于委托可以绑定和调用多个方法,所以会为事件的处理带来方便。类型只需对外公开事件,就可以与外部的其他方法关联,从而实现事件订阅。

假设我们订阅了某新闻平台的邮件通知服务,只要有新闻更新,服务提供方就要发送通知邮件。事件订阅也是如此,前面分析过选用委托作为事件的类型的理由,就是委托可以与其他方法关联,当A方法与X事件进行了关联,只要X事件发生(相当于新闻内容有更新),就会调用作为事件的委托(相当于服务提供方发送通知邮件),因为A方法与X事件关联,所以A方法也会被调用,于是代码就能够响应事件。

图3-32 响应事件的输出信息

事件是委托类型,因此要在类中声明事件,首先要定义用来作为事件封装类型的委托,然后在类中用event关键字来声明事件。为了允许派生类重写引发事件的代码,通常会在类中声明一个受保护的方法,习惯上命名为On<事件名>,然后在这个方法中调用事件。在.NET类库中许多类型都采用这种封装形式。

示例\第3章\Example_14将演示事件的使用方法。该示例运行后,只要用户按下空格键,就会在屏幕中给出提示,如果按下其他键,不做处理。运行结果如图3-32所示。

定义一个委托类型,作为响应按下空格键这一事件的封装类型。

    public delegate void SpaceKeyPressedEventHandler();

定义一个MyApp类,代码如下:

在类中,用定义的委托声明了SpaceKeyPressed事件,然后封装在OnSpaceKeyPressed方法中调用,因为这个表示事件的委托有可能是空的,调用方可能不响应事件处理,即没有与之关联的方法。所以,在调用前必须先判断一下表示事件的委托实例是否为null。

还有一种更简便的写法,不需要写if语句进行判断,而是直接调用事件,但在事件名称后面加上一个“?”(英文的问号),代码如下:

加上“?”符号之后,程序会自动判断SpaceKeyPressed是否为null。如果为null,这行代码就不会执行,直接跳过,如果不为null,就执行这行代码。

在StartRun方法中启动一个无限循环,因为while后面的判断条件是true,true永远都是true,也就成了一个死循环,所以需要用下面这段代码来跳出循环

图3-33 生成事件处理代码

当按下Esc键时,用break语句直接退出循环。

如果按下的键是空格键,就调用OnSpaceKeyPressed方法,这样一来,事件就被触发了。而代码在使用MyApp类时,先创建一个实例,然后通过+=运算符使SpaceKeyPressed事件与相关的方法关联起来。在Visual Studio中,在事件名后面输入+=,再按下Tab键,会生成一个默认的方法名(变量名_方法名),因为这个方法名处于选定状态,可以对其进行重命名,如图3-33所示。如果不需要重命名,再按一下Tab键,就会生成事件处理方法。

方法中的处理代码如下:

Main方法中的代码如下:

运行应用程序后,只要按下空格键,屏幕上就会输出提示信息。

在引发事件时,许多情况下都会考虑传递一些数据。比如一个捕捉鼠标操作的事件,光是引发事件是不够的,事件的处理程序还需要知道用户进行了哪些鼠标操作,是移动了鼠标指针,还是按下了鼠标上的某个键;如果是按下了鼠标上的键,是左键还是右键……可见,在引发事件时,还相当有必要传递一些描述性的信息,以便事件处理代码能够获得更详细的数据。

通常,事件委托有两个参数,一个是Object类型,表示引发事件的对象,即谁引发了事件,多数情况下在调用事件时把类的当前实例引用(this)传递过去。另一个参数是从System.EventArgs派生的类的实例。这是一个标准的事件处理程序的签名,为了规范事件的处理,.NET类库已经定义好一个System.EventHandler委托,专用于声明事件。它的原型如下:

    public delegate void EventHandler(object sender, System.EventArgs e);

引发事件的对象实例将传递给sender参数,而与事件相关的数据则传递给e参数。如果不需要传递过多的数据,可以通过System.EventArgs.Empty静态成员返回一个空的EventArgs对象来传递。

不同的事件要传递的参数不同,显然一个EventHandler委托是不能满足各种情况的。如果针对不同的事件也定义一个对应的委托,数量一旦多起来,既混乱,又不好管理。为了解决这个问题,.NET类库又提供了一个带有泛型参数的事件处理委托,原型如下:

    public delegate void EventHandler<TEventArgs>(object sender, TEventArgs e);

TEventArgs是一个泛型参数,但是TEventArgs应该是System.EventArgs类或者System.EventArgs类的派生类型。

有了EventHandler<TEventArgs>委托,开发者就可以应对各种各样的事件了,因为对于不同的事件,第一个参数是不变的,只是第二个参数的类型有差异。

下面的示例捕捉用户的键盘输入,然后引发KeyPressed事件,在事件参数中传递用户按下的键。运行结果如图3-34所示。

图3-34 响应事件处理结果

示例大致的实现步骤如下:

(1)在Visual Studio开发环境中新建一个控制台应用程序项目。

(2)定义一个KeyPressedEventArgs类,用来存放事件参数,PressedKey属性表示用户按下的键。

(3)定义MyApp类,代码如下:

使用EventHandler<KeyPressedEventArgs>委托声明KeyPressed事件,并通过OnKeyPressed方法来引发事件。在Start方法中,通过一个无限循环来捕捉按键输入,并引发KeyPressed事件。

(4)在Main方法中实例化MyApp类,并关联KeyPressed事件的处理方法,调用Start方法开启循环。

完整的示例代码请参考\第3章\Example_15。

3.13 枚举

枚举可以被认为是一种由多个整数常量组成的类型。枚举中的每个成员都必须是整数(不包括char类型),因此枚举的基础类型支持以下类型:byte、sbyte、short、ushort、int、uint、long、ulong。非整数的值类型,如double是不允许作为枚举的基础类型的。枚举的默认基础类型是int类型。

尽管枚举类型的结构比较简单,只是一系列数值的组合,但是使用枚举类型有两个明显的好处:

(1)严格规范性,防止意外调用。比如,一周有七天,星期日到星期六,某个方法需要传递一个表示一周中某一天的参数,如果使用单个整数值,很难进行规范,程序代码无法事前预知方法的调用方会传递哪个数值作为参数,这会让参数的合法性验证变得十分困难。但是,如果定义了枚举类型作为参数,调用方在传递参数时只能从枚举类型所声明的值中进行选择,就不会出现意外的值。

(2)增强可读性。枚举中的每个值都可以进行命名,代码调用方通过这些名称就能够轻松地推测各个值所指代的含义。比如,一个表示电源开关状态的枚举,假设用0表示关闭,用1表示打开,并且命名为On = 1,Off = 0。代码的调用者只要看到On便知道是表示打开的状态。

枚举类型默认继承自Enum类(由编译器实现),而Enum类从ValueType类派生。据此可以得知,枚举类型属于值类型。

3.13.1 使用枚举类型

第10集

声明枚举类型使用enum关键字,内部各个常数用英文逗号隔开。例如:

    enum Test { a, b, c}

上面代码声明了一个Test枚举,它包含三个成员a、b、c。由于使用了默认方式来定义,所以Test枚举的基础类型是int,而其中的常数值从0开始进行排列,即a的值为0,b的值为1,c的值为2。可以用下面代码来验证

    Console.WriteLine("a的值为:{0}\nb的值为:{1}\nc的值为:{2}", (int)Test.a, (int)Test.b,
    (int)Test.c);

结果输出如图3-35所示的文本信息。

自定义枚举中各常数的值方法和赋值一样,比如

    enum Test { a = 3, b, c}

图3-35 验证枚举的值

这时a的值为3,b和c,没指定具体的值,就以a的值为基础,累加1,所以b的值为4,c的值为5。再举一例

    enum Test { a, b = 10, c}

此例中只为b赋了具体的值,对于a来说,还是默认值0,而b已赋值10,c的值以b的值为基础累加1,所以c的值应为11。

当然,也可以向所有命名成员赋值,如

    enum Test { a = 9, b = 25, c = 0x00EC}

由于Test枚举中所有成员都赋了值,所以此时a、b、c的值就不是连续的整数了,a的值为9,b的值为25,c的值是236(用十六进制来表示)。

要将枚举声明为int以外的整数类型,需要在枚举的类型名称后面加上英文冒号,紧接着是目标类型,例如:

上面代码定义了一个Mode枚举,该枚举基于byte类型,因此其中的各个常数都是字节(byte)类型。

既然枚举是值类型,而且其基础类型与各整数类型有关,那么程序在运行时为枚举所分配的存储空间大小会与它的基础类型相等吗?我们知道,一个byte类型的值占1字节的存储空间,一个int类型的值占4字节的存储空间。那么,相应的枚举的值又会占用多少字节的存储空间呢?

先来完成一个示例程序,通过该示例可以直观地回答上面的疑问。在Visual Studio开发环境中新建一个控制台应用程序项目。

为每种基础类型各声明一个枚举。

在Main方法中使用sizeof运算符来获取并输出每个枚举值的大小,以字节为单位。

    Console.WriteLine("int类型的枚举的大小为{0}字节。", sizeof(intEnum));
    Console.WriteLine("byte类型的枚举的大小为{0}字节。", sizeof(byteEnum));
    Console.WriteLine("sbyte类型的枚举的大小为{0}字节。", sizeof(sbyteEnum));
    Console.WriteLine("short类型的枚举的大小为{0}字节。", sizeof(shortEnum));
    Console.WriteLine("ushort类型的枚举的大小为{0}字节。", sizeof(ushortEnum));
    Console.WriteLine("uint类型的枚举的大小为{0}字节。", sizeof(uintEnum));
    Console.WriteLine("long类型的枚举的大小为{0}字节。", sizeof(longEnum));
    Console.WriteLine("ulong类型的枚举的大小为{0}字节。", sizeof(ulongEnum));

图3-36 输出各个枚举的大小

sizeof运算符可以返回一个对象所占用的内存空间大小,计算单位为字节。运行本程序,会得到如图3-36所示的结果。

从输出的结果中可以看到,枚举值的大小与其基础类型的大小相等。如一个int类型的值大小为4字节,则intEnum枚举的值的大小也是4字节;再如,byte类型的值大小为1字节,则byteEnum枚举的值的大小也是1字节。尽管枚举内部定义了一组数值,但是在同一时刻只能向枚举类型的变量赋其中一个值。即使是按位运算后的枚举也是如此,因为按位运算后,基本类型不会改变,故枚举的值的大小也不会改变。

完整的示例代码请参考\第3章\Example_16。

3.13.2 获取枚举的值列表

由于枚举类型在编译时默认以Enum类为基类,因此Enum类的成员对枚举类型是有效的。通过调用一个名为GetValues的静态方法,将指定枚举类型中所有成员的值列表以数组的形式返回。如果枚举类型在定义时以byte为基础类型,则返回byte类型的数组;如果枚举是以uint类型为基础类型定义的,则返回uint类型的数组。

下面用一个示例来演示如何获取指定枚举类型中的值列表。完整的示例代码请参考\第3章\Example_17。首先定义一个枚举类型,代码如下:

Test枚举基于ushort类型,里面定义了三个值。下面就用Enum.GetValues方法把这些值都取出来,并通过foreach循环将它们逐一输出到屏幕上。

GetValues是静态方法,因此可以直接调用。GetValues方法的原型如下:

    public static Array GetValues(Type enumType);

参数是一个Type对象,Type也是一个类,它包装与类型相关的信息。通过这个参数告诉GetValues方法,代码希望获取哪个枚举类型的值列表。返回值为Array类型,即数组的基类,由于枚举可能基于byte类型,也可能基于int,返回什么类型的数组取决于枚举的数值的类型。将返回类型定义为Array,可以兼容各种类型的数组。不管返回的是byte类型的数组还是uint类型的数组,都是Array的派生类,都是允许的。这里也体现了抽象类的一个作用:能够在运行阶段动态引用派生类的实例。

由于GetValues方法返回的数组类型不是固定的,而是动态决定的,因此可以考虑使用var关键字来声明变量,如上面的

    var values = Enum.GetValues(…);

用var关键字来声明变量不必指定变量的具体类型,而是根据给变量赋的值来推断变量的类型。比如,下面代码中,变量c的类型为字符串类型(string)。

    var c = “xyz”;

图3-37 获取到的枚举类型的值

本示例的运行结果如图3-37所示。

3.13.3 获取枚举中各成员名称

Enum类有两个静态方法可以获取一个枚举类型中各数值的名称。第一个是GetName方法,它的原型如下:

该方法获取单个枚举值的名称。如果希望获取一个枚举类型中所有常数值的名称,应当改用下面的静态方法:

这两个方法使用起来也比较简单,因为是静态方法,所以直接调用即可。接下来用一个示例来演示这两个方法的用法。该示例用.NET类库中的System.DayOfWeek枚举做测试,先通过GetName方法获取常数Thursday的名称,接着使用GetNames方法得到DayOfWeek枚举中所有常数的名称,代码如下:

图3-38 将获取到的名称输出到屏幕

GetNames方法返回一个字符串数组,该数组包含了枚举中每个值所对应的名称。输出结果如图3-38所示。

完整的示例代码请参考\第3章\Example_18。

3.13.4 枚举的位运算

第11集

既然枚举类型是以整数值为基础的(无论是int还是其他整数类型),故可以对枚举中的各个值进行位运算,比如按位“与”、按位“或”等运算。

通常,如果考虑让枚举中的值能进行位运算,应当在定义枚举类型时附加上FlagsAttribute特性,有关特性的使用会在3.14节中介绍。

下面用示例来演示。完整的示例代码请参考\第3章\Example_19。定义一个Test枚举,记得要加上FlagsAttribute特性。

Test枚举包含四个常数,换算成二进制分别为0、1、10、100。这样声明可以方便理解其中是如何进行位运算的,比如要将Music和Video进行或运算,其计算过程为

    01 | 10 = 11

因为对于或运算,只要其中有一个值为1,其结果就为1,所以通常会通过或运算来对多个枚举值进行合并。把上面的Test枚举看作一个三位数的二进制数,如果枚举值中包含Music,则第一位为1,合并后的值为001;如果用这个值再与Video进行合并,得到的值就是011;接着再与Text组合,最终的值就会变为111。

那么,如何去判断一个经过组合后的枚举变量是否包含指定的值呢?这就要用到与运算了,原理就是在与运算中,必须两个操作数同时为1时,计算结果才会为1。利用这一点,如果要判断上面的组合值111中是否包含Video,可以这样运算

    判断111 & 010是否等于010

把111与010进行与运算,第一位和第三位都为0,所以最终的结果取决于第二位,如果为1就表明组合的值中包含Video,否则就不包含Video。

下面代码声明变量v,并把Music、Video和Text三个值进行组合,然后赋给变量v。

    // 变量v相当于二进制111
    Test v = Test.Music | Test.Text | Test.Video;

检查变量v中是否包含Text。

因为v中确实包含了Text,所以(v & Test.Text) == Test.Text条件成立。屏幕上会输出“变量v中包含了Text。”

如果要从某个组合值中去掉某个值,可以先将该值取反(运算符为~)。比如要从v中去掉Music,先将Music取反(就是将每个位上的1变为0,0变为1),即~001 = 110。再把这个取反后的值与组合数进行与运算,即111 & 110 = 110,如此一来,就把Music所在位上的值去掉了,如下面代码所示

图3-39 输出结果

由于Music被去掉,因此v中不再包含Music的值了。示例的运行结果如图3-39所示。

枚举值也是个整型值,就算声明的枚举类型不带有FlagsAttribute特性也能进行组合运算,为什么还要附加上FlagsAttribute特性呢?在上面的示例中并不能看出区别,因此再用一个示例来验证附加了FlagsAttribute特性的枚举与未附加FlagsAttribute特性的枚举到底有没有不同之处。

在Visual Studio开发环境中新建一个控制台应用程序项目(完整的示例代码请参考\第3章\Example_20),接着定义两个枚举类型。其中,A枚举不带FlagsAttribute特性,而B枚举则加上FlagsAttribute特性,两个枚举中定义的常数值相同。代码如下:

把上面枚举中定义的三个数值进行组合(即或运算),得到二进制结果为111,转换为十进制就是7。因此,上面两个枚举中,如果把三个数值都进行组合,得到的结果就是7。在代码中定义一个int类型的变量,赋值7,然后分别把这个7强制转换为A枚举和B枚举,代码如下:

图3-40 屏幕上输出的内容

运行应用程序,屏幕上输出的内容如图3-40所示。

从结果中可以看到,不带FlagsAttribute特性的枚举,尽管它的三个值的组合结果为7,但是强制转换为A枚举类型后无法识别,所以只能输出原值7;而附加了FlagsAttribute特性声明的B枚举能够识别出7是V1、V2、V3三个枚举值的组合,因此转换为B枚举类型后会输出正确的结果。

3.14 特性

特性可以为程序集、类型及类型内部的各种成员添加扩展信息,用于表示一些附加信息。通常,表示特性的类都派生自System.Attribute类,比如AttributeUsageAttribute类等。

在C#语言中使用特性必须放在一对中括号(英文)中。默认情况下,特性将应用于紧跟其后的对象。举例如下:

    [Serializable]
    public class A { }

在上面代码中,SerializableAttribute特性之后定义了A类,因此该特性将应用于A类。另外,通过上面的例子可以发现,在C#中可以略去“Attribute”,因此在代码中只需输入Serializable即可,但一定要注意要把特性放在一对中括号中。

SerializableAttribute类的原型定义如下:

    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.
    Enum | AttributeTargets.Delegate, Inherited = false)]
    [ComVis (true)]
    public sealed class SerializableAttribute : Attribute {  }

查看SerializableAttribute类的定义可知,在定义特性类时也可以应用其他特性,其中使用最多的就是AttributeUsageAttribute,其定义如下:

    [Serializable]
    [AttributeUsage(AttributeTargets.Class, Inherited = true)]
    [ComVisible(true)]
    public sealed class AttributeUsageAttribute : Attribute

AttributeUsageAttribute类在定义时也应用了AttributeUsage特性。该类指定特性类的适用范围,用AttributeTargets枚举来表示,比如特性应用于程序集,或者应用于类、类的属性等;也可以组合多个应用目标,因为AttributeTargets枚举也有FlagsAttribute特性,作为标记的枚举可以组合使用。

如果特性存在带参数的构造函数,可以在特性后用一对小括号括起来,然后在其中传递参数,和调用普通类的构造函数一样,例如

    [AttributeUsage(AttributeTargets.Class)]
    public class MyInfo : Attribute

如上面例子所示,AttributeUsageAttribute类有一个带一个参数的构造函数,参数类型为AttributeTargets枚举,因此在上面例子中,将AttributeTargets.Class传递给AttributeUsageAttribute类的构造函数。

如果要为特性类的属性或字段赋值,也是写到一对小括号中,用英文逗号分隔,如

    [MyInfo(AppName = "Compute", Ver = "1.0.0")]
    public class Test { }

也可以同时附加多个特性,比如

    [MyInfo(AppName = "Compute", Ver = "1.0.0")]
    [Serializable]
    public class Test { }

因此,在上面代码中,Test类应用了MyInfo和Serializable两个特性。同样,也可以把多个特性放到一对中括号中,用英文逗号分隔,如

    [MyInfo(AppName = "Compute", Ver = "1.0.0"), Serializable]
    public class Test { }

3.14.1 自定义特性

定义特性类与定义普通类是一样的,既可以声明构造函数、字段、属性、方法等成员,也可以派生子类,但有一个前提,那就是得从System.Attribute类或者System.Attribute的子类派生。总的来说,就是要表明它是一个特性类。

下面代码定义了一个AppInfoAttribute特性。

AppInfoAttribute类可以用于类、方法和属性上,并声明Title和VerNo两个公共属性。按照习惯,特性类名后应跟上Attribute作为后缀,在C#语言中使用时可以把后面的Attribute省略。当然,特性类名也可以不带Attribute结尾,加上Attribute作为后缀只是为了方便识别该类是特性类。在自定义特性类时,应当加上Attribute后缀,既方便自己阅读代码,也能方便其他人更容易识别出来。

定义了特性类后,就可以应用到其他类型中了,如下面代码所示

这里要注意,当AppInfoAttribute特性用于字段时发生编译错误,因为AppInfoAttribute类在定义时已经指明它只能用于类、方法、属性,并未指定其可用于字段。

3.14.2 将特性应用到方法的返回值

在默认条件下,特性将应用于跟随其后的对象,如在类的声明前面加上特性,就是将特性应用于类。将特性应用于方法也一样,在方法声明的前面加上特性;同理,也可以为方法中的参数应用特性,如下面代码所示

    public static string Run([In]string pt, [Optional]int x) { return string.Empty; }

为参数应用特性只需放在参数前面即可。但是,如果要为返回值应用特性,那么是不是把特性放在返回值前面就可以了呢?就像这样

    public [MarshalAs(UnmanagedType.SysInt)] int Compute()

这样做是错误的,编译无法通过,那是不是说,特性就不能应用于返回值了呢?不是的,在解决这个疑问之前,需要了解一些知识。

前面曾提到,默认情况下特性是应用于跟随其后的对象的,因此在许多时候,在使用特性时都会省略表示特性目标的关键字。以下是特性应用于目标对象时的完整格式。

    [<目标> : <特性列表>]

应用目标关键字与特性列表之间用一个冒号(英文)隔开,有效的特性目标关键字及其相关说明如表3-2所示。

表3-2 有效的特性目标关键字及其相关说明

将特性应用到方法上,并且注明特性的应用目标为return,如下面代码所示

    [return:MarshalAs(UnmanagedType.SysInt)]
    public int Compute() { return 0; }

3.14.3 通过反射技术检索特性

本节主要介绍如何查找特性,需要用到反射技术。本书在前面讲过,特性可以理解为附加在类型上的一些扩展信息,因此可以通过在类型中找到指定的特性来验证代码的调用方是否符合特定的要求。

下面示例将定义一个TypeInfoAttribute特性,它有一个Description属性,表示类型的描述信息。接着通过反射技术来获得这些描述信息。

在Visual Studio开发环境中新建一个控制台应用程序。在Program类中声明一个TypeInfoAttribute特性类,代码如下:

AttributeTargets.All表示该特性可以应用于所有目标。接下来声明一个枚举和一个类,并应用TypeInfoAttribute特性。

在Main方法中把以上定义的两个类型的TypeInfoAttribute特性读出来,代码如下:

运行应用程序后,得到如图3-41所示的结果。

图3-41 输出特性的属性值

完整的示例代码请参考\第3章\Example_21。

3.15 数组

第12集

数组是从单词array翻译过来的,array的大致含意是“排列,大量,一系列”。因此,从单词的字面意思也可以得知,数组就是把一系列类型相同的元素聚集在一起的一种数据结构。比如,将1、2、3三个int类型的数值放到一起,便构成了一个带有三个元素的int数组。

3.15.1 定义数组的几种方法

定义表示数组的变量与声明普通变量一样,只是要在类型后加上一对空的中括号([]),例如:

    char[] chars;

上面代码声明了一个char数组,即数组中的每个元素都是char类型。声明数组后就要对其赋值,一种方法是明确指定数组的元素个数。

    int[] nums = new int[3];
    nums[0] = 6;
    nums[1] = 20;

上面代码通过new运算符创建数组实例,并在后面的一对中括号中指定数组中元素的个数。在上面代码中,nums数组中包含3个int类型的元素。需要通过索引来访问数组中的元素,数组的索引是从0开始的,即第一个元素的索引为0,第二个元素的索引为1,第三个元素的索引为2……以此类推。索引值依旧包含在一对中括号中,如上面例子中,nums[0]就是访问数组中的第一个元素。上面代码在创建数组实例后,将第一个元素的值设置为6,第二个元素的值设置为20,而第三个元素的值是0,因为int类型的变量默认值为0。也就是说,在创建数组实例的同时,会对其中所包含的元素进行初始化,如果数组包含的元素是引用类型(比如string类型)就初始化为null。

另一种方法是直接用元素列表来填充数组,数组会根据所列出的元素来计算数组的大小,比如:

    string[] names = new string[] { "abc", "def" };

以上代码在创建数组实例的同时,也对各元素进行了初始化,names数组中定义了两个元素。还可以简写为以下形式

    string[] names = { "abc", "def" };

数组默认继承System.Array类,所以数组本身是引用类型。从Array类的派生是由编译器自动完成的,开发者不能手动从Array类派生新类。不过,在代码中定义的数组变量可以使用Array类中所公开的成员。比如,可以通过访问Length或LongLength属性来获取数组中所包含元素的个数。Length属性是int类型,用得较多,如果数组中的元素很多,超出了int的有效范围,则可以访问LongLength属性来得到元素个数。

可以通过for循环来访问数组中的每个元素,如

由于Array类实现了IEnumerable接口,因此还可以用foreach语句来访问数组中的每个元素。例如:

以下示例介绍了数组的基本使用方法。

(1)在Visual Studio开发环境中新建一个控制台应用程序。

(2)声明一个int数组,并进行赋值,然后打印数组中的每个元素。代码如下:

(3)创建一个string数组并初始化,然后输出数组中的元素。

    string[] strs = { "cat", "car", "food" };
    Console.WriteLine("\n\n字符串数组中的元素列表:");
    // 通过Join方法将字符串数组的各元素进行拼接
    Console.WriteLine(string.Join(", ", strs));

图3-42 数组示例的运行结果

(4)修改上面创建的字符串数组中第二个元素的内容,然后再输出一次。

    // 修改数组中的元素
    strs[1] = "sound";
    // 重新输出数组的元素
    Console.WriteLine("\n修改后的数组:");
    Console.WriteLine(string.Join(", ", strs));

整个示例的运行结果如图3-42所示。

完整的示例代码请参考\第3章\Example_22。

3.15.2 多维数组

前面所提及的是一维数组,其实数组是可以定义为多维的,但是在实际编程中用得不多。多维数组中通常也仅用到二维数组,维度较高的数组极少会用到。

声明多维数组的方法和一维数组一样,在一维数组的声明中使用的是一对空的中括号,而对于二维数组,在中括号中加上一个逗号分隔,以表示两个维度,如

    int[ , ] arr;

同理,如果要声明三维数组,中括号中应使用两个逗号,如下面代码所示

    int[, ,] arr;

访问多维数组中的元素,方法与访问一维数组中的元素一样,索引依旧从0开始。只是在中括号内部要指明元素的位置,如

    arr[0, 2] = 50;

每个维度的索引用逗号隔开。在上面的代码中,该元素位于第一维度的0索引和第二维度的2索引处。从字面上不太好理解,因此还是通过实例来研究多维数组的结构。此处将以二维数组为例。

在Visual Studio开发环境中新建一个控制台应用程序项目(完整的示例代码请参考\第3章\Example_23)。待项目创建后,在Main方法中声明一个int类型的二维数组,代码如下:

    // 声明二维组数
    int[,] arr1 = new int[2, 3];

向数组中的元素赋值。

    // 为数组中的元素赋值
    arr1[0, 0] = 1;
    arr1[0, 1] = 2;
    arr1[0, 2] = 3;
    arr1[1, 0] = 4;
    arr1[1, 1] = 5;
    arr1[1, 2] = 6;

接着输出数组中的元素。

得到如图3-43所示的结果。

在上面的代码中,二维数组中元素的总个数等于各维度上元素个数的乘积。比如上面代码中的arr1数组,第一维度是2个元素,第二维度是3个元素,因此该数组的元素总数为2×3 = 6。

可以把一维数组用一维表格来表示,二维数组用二维表格来表示,于是便绘制出图3-44模拟数组内部各元素的排列结构。

图3-43 输出二维数组的元素

图3-44 数组内部元素的排列结构

与一维数组类似,在实例化二维数组时,可以不指定元素个数,而是直接向数组中填充元素,数组实例会自动计算元素的个数,如下面代码所示

图3-45 char二维数组中的元素

对于多维数组,可以通过GetLength方法获取特定维度上的元素个数,参数为从0开始的整数值,表示维度,即0表示第一维度,1表示第二维度,依此类推。最后的输出结果如图3-45所示。

3.15.3 嵌套数组

要注意多维数组和嵌套数组二者之间的区别,嵌套数组也叫数组的数组,或叫交错数组,是通过以下方式来声明变量的

    int[3][2] arr;

数组中的每个元素也是数组,也就是数组中包含数组。请考虑下面的代码

在上面代码中,声明了一个嵌套数组,该数组从外到内有两层,最外层包含三个元素,而每个元素又是一个char数组。第一个char数组包含两个元素,第二个char数组也包含了两个元素,第三个char数组则包含了三个元素。

嵌套数组要比多维数组复杂,它是从外向内一层一层地进行嵌套。其实在声明嵌套数组时,可以通过中括号的对数来确定嵌套数组所包含的层数。比如,int[][]表示该数组包含两层数组,int[][][]则表示其中包含三层数组。

下面用一个示例来演示一个三层嵌套的数组,嵌套数组变量的声明如下:

该数组有三个层次(int[][][]),第一层有三个元素,每个元素又是一个两层嵌套的数组(in[][]),第二层中每个元素又是一个数组(int[]),第三层才是单个int数值。可以用图3-46描述这个嵌套数组的内部层次结构。

把这个嵌套数组的所有元素输出到屏幕。

得到如图3-47所示的运行结果。

图3-46 三层嵌套数组的内部层次结构

图3-47 在屏幕上输出嵌套数组

完整的示例代码请参考\第3章\Example_24。

嵌套数组的结构有些类似Windows操作系统中的文件目录结构,可以把嵌套数组的层次与系统中的文件夹层次作类比,从外向内层层嵌套,而最后一层便是数组中的单个元素,类似于文件夹内部的单个文件。在实际开发过程中很少会使用嵌套数组,也不建议读者使用,如果对嵌套数组的层次结构理解不清楚,很容易造成不必要的错误;况且,为了方便他人阅读代码,也不宜将数组结构定义得过于复杂。

3.15.4 复制数组

复制数组就是把源数组中部分或全部元素复制到另一个数组中。Array类公开了两个方法,可以完成数组的复制功能。

(1)Copy方法:它定义为静态方法,可以直接调用,支持复制一维数组和多维数组。

(2)CopyTo方法:该方法为实例方法,必须由Array类的实例来调用。此方法只能复制一维数组。如果调用CopyTo方法来复制多维数组,会引发错误。

用于接收复制元素的数组的容量不能小于源数组的容量。如果源数组的大小为3,那么目标数组的大小可以大于或等于3,但不能小于3。如果目标数组的大小为2,就无法容纳复制过来的元素。如果目标数组的大小大于源数组的大小,只用复制过来的元素修改相应的位置的值,其他元素的值不变。例如,源数组大小为2,而目标数组的大小为4,如果复制数组是从目标数组的0索引处开始写入,那么目标数组只有前两个元素被复制过来的元素改写,而另外两个元素不变。

以下示例将进行三次数组复制,并且每次复制后都在屏幕上输出目标数组中的元素,完整的示例代码请参考\第3章\Example_25。

第一次,复制一维数组。

第二次,复制二维数组。

第三次,复制嵌套数组。

最终的输出结果如图3-48所示。

3.15.5 反转数组

反转就是数组中的元素反过来重新排序,比如某数组中元素的次序为1、2、3,数组反转后,元素的次序就变为3、2、1。调用System.Array类的Reverse方法实现数组反转,该方法为静态方法,可以直接调用。

以下示例将演示如何反转数组中元素的次序(完整的示例代码请参考\第3章\Example_26)。

示例程序运行后,可以看到如图3-49所示的结果。

图3-48 最终的输出结果

图3-49 数组反转前后的输出对比

3.15.6 更改数组的大小

数组实例一旦被创建后,其大小已经固定了,但是通过Array类的以下方法可以修改数组实例的大小

    public static void Resize<T>(ref T[] array, int newSize);

该方法是静态方法,且带有类型参数T,这属于泛型的一种形式,本书在后面会讲解泛型有关的知识。简单地说,使用类型参数T可以扩大Resize方法的适用范围,即可以将任意数组实例传递给该方法的array参数。如果T为int类型,就可以传递int的数组实例进去;如果T为string类型,则可以传递string数组实例进去。

newSize表示新分配的大小。Resize方法是用newSize来创建一个新的数组实例,再把旧数组实例的元素复制到新的数组实例中取代原来的数组,通过这种方法间接达到修改数组大小的目的。

在Visual Studio开环境中新建一个控制台应用程序项目。声明一个大小为3(包含3个元素)的int数组,随后调用Array.Resize<T>静态方法修改数组的大小为7(包含7个元素)。为了验证原来的数组实例是否被新创建的数组实例替代,代码在修改前后分别输出数组的大小和哈希值。如果原来数组的实例被替换了,那么两次输出的哈希值会不同。具体代码如下:

    // 创建数组实例
    int[] arr = new int[3];
    // 修改大小前输出
    Console.WriteLine("数组的大小:{0},哈希值为{1}", arr.Length, arr.GetHashCode());
    // 修改数组的大小
    Array.Resize<int>(ref arr, 7);
    // 修改大小后再次输出
    Console.WriteLine("数组的大小:{0},哈希值为{1}", arr.Length, arr.GetHashCode());

图3-50 数组的大小被修改前后的输出对比

按下F5键调试运行,得到如图3-50所示的结果。

第一次输出的大小为3,第二次输出的大小为7,但是哈希值不相同。这说明Resize<T>方法是通过替换旧数组的方式来修改数组大小的。

完整的示例代码请参考\第3章\Example_27。

3.15.7 在数组中查找元素

对数组的操作基本上都是由Array类来负责,因而该类也提供了一系列方法来帮助开发者在数组中进行查找。这些方法按照查找结果划分,大体可以分为两类,下面将分别介绍。

1.查找元素的索引

此查找方式将返回被查找到的元素的索引,如果未找到,就返回-1。有两类方法可用,第一类方法是按照单个元素值查找,第二类方法则比较灵活,可以通过System.Predicate<T>委托来自定义查找过程,具体可参考表3-3。

表3-3 查找数组中元素索引的方法

上面方法的参数中用到了Predicate委托,它的定义原型如下:

    public delegate bool Predicate<in T>(T obj)

其中,T是类型参数,该委托接受T类型的参数,并返回bool类型的方法;obj是数组中待查找的元素,如果obj符合查找条件,返回true,否则返回false。

下面用一个示例分别演示如何使用上面所列的方法来查找元素的索引。

声明一个数组变量用于测试。

数组中包含五个string类型的元素。随后分别用IndexOf、LastIndexOf、FindIndex和FindLastIndex四个方法来对测试数组进行查找,并在屏幕上输出查找到的索引。

“check”是数组的第二个元素,索引为1,故index1的值为1;测试数组中有两个“ask”元素,分别是第一个和第三个,LastIndexOf方法返回匹配的最后一项的索引,虽然索引0和2处都有“ask”元素,但是由于索引2处是最后一处,故index2变量的值为2;在测试数组中有四个元素是以k结尾的,但FindIndex只返回第一个匹配项的索引,因为第一个元素“ask”就是以k结尾的,符合条件,所以index3的值为0;FindLastIndex方法返回最后一个以k结尾的元素“ink”的索引,所以index4变量的值为4。

下面代码是自定义查找方式的FindProc方法的处理过程。

图3-51 屏幕上输出的查找结果

运行应用程序后,得到如图3-51所示的运行结果。

完整的示例代码请参考\第3章\Example_28。

2.查找元素自身

这种查找方式的结果不是返回元素在数组中的索引,而是直接返回元素自身,也就是返回所找到的元素的值。Array类提供三种方法用于查找元素。

(1)Find方法:查找符合条件的元素,如果找到,就不再往下查找;如果没有找到满足条件的元素,则返回类型的默认值。比如,如果要查找的目标类型是int,在找不到符合条件的元素时就返回int的默认值0。

(2)FindLast方法:查找满足条件的元素,并返回符合条件的最后一个元素,和FindLastIndex方法类似。比如,一个整型数组包含1、2、3、4四个元素,如果查找的条件是小于4的元素,那么符合条件的元素有1、2、3三个,FindLast方法将返回最后匹配的元素,即返回3。

(3)FindAll方法:按照指定的条件进行查找,返回所有符合条件的元素,以数组的形式返回。例如,一个int数组包含1、2、3、4四个元素,查找条件为小于3的元素,则FindAll方法将返回一个新的int数组,该数组包含符合条件的两个元素1和2。

以下示例介绍如何使用上述方法在数组中查找元素。

(1)在Visual Studio开发环境中新建一个控制台应用程序项目。

(2)定义一个int数组用于测试。

    // 声明数组变量
    int[] arr = { 3, 6, 35, 10, 9, 13 };

(3)分别使用Find、FindLast和FindAll三种方法在数组中查找小于10的元素,并输出查找结果。详细代码如下:

数组中小于10的有三个元素:3、6、9。Find方法只返回第一个符合条件的元素,所以返回3;FindLast方法返回符合条件的最后一个元素,所以返回9;FindAll方法返回所有符合条件的元素,因此返回3、6、9。

(4)以下代码定义FindCallback方法,用于Predicate<T>委托。如果元素小于10则返回true,否则返回false。

图3-52 查找结果

示例的运行结果如图3-52所示。

完整的示例代码请参考\第3章\Example_29。

3.15.8 灵活使用ArrayList类

同一个数组实例中只能放置类型相同的元素。本节将介绍一个可以在其中放置不同类型的类似数组结构的类——ArrayList(位于System.Collections命名空间)。

ArrayList类不仅可以添加不同类型的元素,而且容量会随着新元素的添加自动增长,也可以通过Capacity属性修改ArrayList实例的容量。

ArrayList类虽然可以放置不同的类型,但也会在一定程度上影响性能,因此最好不要向ArrayList中添加过大的元素。

ArrayList类的使用并不复杂,下面将通过一个示例来演示。

启动Visual Studio开发环境,并新建一个控制台应用程序项目。核心代码如下:

代码首先创建一个ArrayList实例,随后向ArrayList实例依次添加一个int类型的元素、一个double类型的元素、一个string类型的元素和一个long类型的元素。

接着读出第一个和第四个元素,并输出到屏幕。然后,调用RemoveAt方法删除最后一个元素,索引为list.Count – 1,因为索引是从0开始的,所以最后一个元素的索引应为元素总数减1。删除元素前后向屏幕输出ArrayList实例的元素个数,以方便进行对比。

图3-53 应用程序的输出结果

如果要删除ArrayList中指定的元素,而不是通过索引操作,可以使用Remove方法,该方法调用时向参数传递指定元素的具体值,而RemoveAt方法在调用时,是通过传递要删除的元素的索引作为参数的。

示例的运行结果如图3-53所示。

完整的示例代码请参考\第3章\Example_30。 ql62KMSQLGTSW19saNq/WLWAcUUdQHSmh9lxHT8ktufSWPoew95Ye8I3XcSorj6r

点击中间区域
呼出菜单
上一章
目录
下一章
×