C# 篇基础知识3——面向对象编程
面向过程的结构化编程,例如1972年美国贝尔研究所推出的C语言,这类编程方式重点放在在定函数上,将较大任务分解成若干小任务,每个小任务由函数实现,分而治之的思想,然而随着软件规模的不断扩张,软件的复杂程度空前提高,例如Vista系统代码达到5000万行,安装光盘有2.5GB。这种情况下,面向过程的自顶向下按功能将软件分解成不同模块的开发方式,分解模块时很难保持各模块的独立性,使程序员设计模块时很难排除其他模块的影响,要同时考虑众多模块,故随着程序规模的扩大,需要记住的细节越来越多,到一定程序时就变得难以应付了。另外,数据与代码相分离的情况下,程序员往往很难理清数据和操作之间的联系。从而出现了软件危机。面向对象编程(Object-Oriented Programming,OOP)面向对象编程(Object-Oriented Programming,OOP)是一种强有力的软件开发方法,在这种方法中,数据和对数据的操作被封装成“零件”,人们用这些零件组装程序。面向对象编程的组织方式和人们认识现实世界的方式一致,符合人们的思维习惯,大大减轻程序员的思维负担;同时它有助于控制软件的复杂性,提高软件的生产效率。所以面向对象方法得到广泛应用,已成为目前最流行的软件开发方法。20世纪80年代初,贝尔实验室在C语言基础上设计了C++语句,1995年Sun公司提出了Java语言,2000年微软公布了C#语言。
面向对象编程和结构化编程并不矛盾,实际上面向对象编程的局部就是由结构化程序单元组成的。
1.面向对象的基本概念
主要有类、封装、接口和对象等。
类,每一类事物都有特定的属性和行为,这些不同属性和行为将各类事物区别开来,面向对象编程采用类的概念,将事物编写成一个个的类,用数据表示事物的属性,用函数实现事物的行为。
封装,正如开车的无需知晓汽车内部结构和原理,它们已被汽车工程师封装在汽车内部,仅需利用提供给司机的一个简单使用接口,司机操纵方向盘和各种按钮就可以灵活自如开动汽车。面向对象技术把事物属性和行为的实现细节封装在类中,形成一个个可重复使用的“零件”,这些“零件”可被成千上万程序员在不必知晓其内部原理情况下使用。这样程序员能够充分利用他人编写好的“零件”,将主要精力集中在自已专门的领域。
接口,人们通过类的接口使用类,程序员在编写类时精心地为其设计接口,既为方便其它程序员使用,也有利于类的升级改造。
对象,类是一个抽象的概念,而对象是类的具体实例,例如人是一个类,李白、杜甫等都是对象。即类是抽象的概念,对象是真实的个体。
2.创建类和对象
使用Class ***{}的方式定义类,未在Class前添加public的类只能在相同命名空间内部使用,为了能够在其它命名空间使用类,要在Class前添加public标志符。类的成员变量称为Field,也即是字段,类的属性用变量表示,类的行为用方法实现。类通过公有成员实现接口,让界可以通过接口使用类的功能。
C#通过new 运算符创建对象,执行该语句时系统先为对象分配相应的内存空间,然后通过类的构造函数初始化类的成员变量(每个类都有一个默认的与类同名的构造函数),这种创建对象的过程叫做类的实例化。如果创建了同一个类的多个对象,则它们共享方法的代码,但不共享数据成员,每个对象都会在内存中开辟新的空间来存储自己的数据成员。C#3.0中加入新特性——对象构造器,使得对象的初始化工作变得格外简单,我们可以采用类似于数组初始化的方式来初始化类的对象。比如:Cat doraemon = new Cat { name = "Doraemon", age = 8 };。
(1)属性
用公有方法读写变量不但可以对数据进行合法性检查,而且提高了类的封装性,一箭双雕。这种专门用来读写数据的方法称为访问器(Assessor)访问器虽然解决了变量 age 的访问问题,但是人们还是习惯于把年龄作为一个变量对待,用方法访问不符合人们的思维习惯。
为了解决这个问题,C#设计了一种特殊的语法——属性(Property)。在属性中,定义了get和set两个访问器,get访问器用来读取变量的值,set访问器用来设置变量的值。set访问器没有声明显式参数,但它有一个名为value的隐式参数。属性的运行方式和方法相似,因此属性可以看作特殊的方法,但属性的使用方式和变量完全相同。无论何时使用属性,都会在后台隐式地调用get访问器或set访问器,并执行访问器中的代码。每个属性背后都对应着一个变量,我们一般让属性和它所对应的变量同名,只是将首字母大写,以示区别。比如变量age相对应属性Age。有时候属性很简单,get和set里面只有取值和赋值,没有其它逻辑代码,这种属性可以通过自动属性来快速定义public int Name { get; set;}。这时编译器会自动创建一个与Name属性相关的私有变量(仅将属性名首字母改成小写)以及Name属性的定义代码。总之,自动属性包含两层含义,先创建一个私有变量,然后创建该私有变量的属性。
(2)构造函数
创建对象时,系统先为对象的成员变量分配内存,然后通过构造函数(Constructor)初始化对象的成员变量。
构造函数,当未定义构造函数时,编译器会为每个类分配一个默认构造函数,它将未明确赋值的整型字段和字符串字段分别初始化为0和null。通过自定义带参数的构造函数,带参数的构造函数可以把类 的员变量初始化为指定的值。构造函数是一种特殊的函数,它必须和类同名,并且没有返回类型(连void也没有)。当我们自定义了构造函数后,默认构造函数就失效了,要想继续使用无参数的构造函数,必须显式定义。自定义构造函数的函数体也可以为空,这时系统会用默认值初始化类的变量成员。
析构函数和垃圾回收,不用的对象要及时删除以释放内存空间,在传统的面向对象设计中用类的析构函数(Destructor)删除对象。析构函数也与类同名,只是要在函数名前加符号~,它不能带任何参数,也没有返回值。由于定义类时编译器会自动生成一个缺省的析构函数,所以一般情况下没有必要编写析构函数,而且由于C#设计了非常完善的垃圾回收机制,一般也不用向析构函数里添加代码。析构函数通常用来释放对象使用的非托管资源。当对象即将离开作用域时,系统自动调用对象的析构函数,释放对象所占的资源。然而在大型的程序中,有时有些对象虽然不再使用了,但离作用域结束还有相当长的时间,在这期间,对象仍然占用内存,浪费资源。C#专门设计了一套回收资源的机制——垃圾回收器。当垃圾回收器确定某个对象已经无用时,就会自动删除该对象,释放内存空间。在这套机制下,内存自动回收,无需人工干预,解决了常常困扰C++程序员的“内存泄露”问题。总之在C#中删除对象的工作是由垃圾回收器负责完成,析构函数通常用来释放对象使用的非托管资源。
.NET类库已经为我们提供了大量的类,提供了大量现成的“轮子”供我们用,例如DateTime、String等,这样就不用重复地制造了。
(3)静态成员
类的静态成员用来描述类的整体特征,静态成员在内存中只有一份,为类的所有对象共享,声明方式:public static int wolvesCount=0;。用 static 关键字修饰的变量称为静态变量,没有用static 关键字修饰的变量称为实例变量,在C#中,实例变量通过对象名引用,而静态变量通过类名引用。
静态方法,一般认为行为是由具体对象发出的,但在某些情况下,对象的概念非常模糊例如Math类,直接通过类名调用方法反而更符合人们思维。因此,可以将类的方法声明静态方法,静态方法用static 关键字声明,调用静态方法时不必事先创立对象,直接通过类名引用。
(4)常量成员
类的const常量只能在声明的时候初始化,不能在其它地方赋值,声明方式例如public const double PI=3.1415926;。类的const常量成员是隐式静态的,为所有类对象共有,通过类名来引用,虽然const常量默认静态,但不能用static关键字显式声明。
readonly常量,对于那些在类的具体对象中固定的常数,但在不同对象中可有不同值的变量,通常用readonly常量实现。例如旅店类的房间总数,不同旅店的房间总数不同,但一个旅店的房间总数通常是固定的。一般把readonly常量初始化代码放在了构造函数。
(5)重载
C#提供了方法重载的方式,即允许在一个类中定义两个或多个名称完全相同的方法,但要求这些名称相同的方法必须具有不同的参数类型(参数数据类型不同或参数个数不同或同时具有这两点)。方法重载调用原则是参数最佳匹配,即系统调用 参数类型最匹配的那个方法。例如通常在定义类的构造函数时使用方法重载。
重载运算符,重载运算符由关键字operator声明,必须定义为静态。定义方法:pubic static ***(返回类型) operator +(或-*/) (*** z1,***z2){…}。例如:
class Complex
{ public static Complex operator +(Complex z1, Complex z2)
{ return Add(z1, z2);} …}
(6)索引
使用类的索引,可以像访问数组那样访问类的数据成员,定义类索引的方式就像以定义属性的方式定义类的数组成员。例如:
class cube{
private double length;
private double width;
private double height;
…
public double this[int index]
{
get
{ switch(index)
{ case 0:return length;
case 1:return width;
case 2:return height:
default: throw new IndexOutOfRangeException(“下标出界”);}
}
set{ switch(index)
{case 0: lenth=value;break;
case1 :width=value;break;
case 2:height=value;break;
default: throw new IndexOutOfRangeException(“下标出界”);}
}
}
}
索引的函数体与属性类似,也是用get 和set 访问器。get 访问器用于获取成员变量的值,set 访问器用于为成员变量赋值。索引的使用方法和数组完全一样,如果创建了一个名为 box 的Cube 对象,就可以用box[0],box[1],box[2]分别表示立方体的长、宽、高了。在数组中,下标只能为整数,在索引中,有了更灵活的选择,既可以为 int 型,也可以为double、string 等类型。例如:
class Cube
{
public double this[string indexString]
{
get
{ switch (indexString)
{ case "length": return length;
case "width": return width;
case "height": return height;
//当下标出界时,我们抛出一段异常。
default: throw new IndexOutOfRangeException("下标出界!");}
}
set
{ switch (indexString)
{ case "length": length = value; break;
case "width": width = value; break;
case "height": height = value; break;
default:throw new IndexOutOfRangeException("下标出界!");}
}
}
...
}
C#还为我们提供了多维索引,只需提供多个下标即可,比如Matrix 类中的多维索引public double this[int rowIndex,int columnIndex]这时需要嵌套的switch 语句或双重循环语句等方式来实现。
3.值类型和引用类型
内存中有一块称为栈的区域,用来存储整型、实型、布尔型和字符型等基本类型。操作系统通过栈指针中存储的地址读写栈中的数据,当栈为空时,栈指针指向栈的底部,随着数据的不断入栈,栈指针不断向栈顶部移动,但始终指向栈中下一块自由空间。栈对数据的操作总发生在栈的顶部,最后入栈的变量最先弹出,最先入栈的数据最后弹出,因此先入栈数据的作用域总比后入栈的要长,后入栈数据的作用域嵌套在先入栈数据之中。栈的这种工作方式称为后入先出(Last in first out,LIFO)。整型、实型、布尔型、字符型等简单数据和结构体存储在栈中,称为值类型变量(Value type)。
引用型变量,在类中,我们希望成员变量被构造函数初始化后,即使退出构造函数,这些变量仍然存在,以便在需要的地方使用,为此C#把类的成员变量存储在堆(Heap)上。内存中创建类的一个对象的过程分两步:
第一步在堆中创建对象:系统在堆中划分一块20 字节的空间用于存储类对象的成员变量,并调用构造函数初始化它们,这样一个对象就被创建了。
第二步在栈上创建引用符:系统在栈中分配4 字节的空间,存储类对象在堆中的首地址。这种存储于栈中的指向堆中对象的地址称为引用符,系统通过引用符找到堆中的对象。
称这种存储在堆上的对象称为引用型变量,引用型变量的垃圾回收机制:在实际程序中,可能会有多个引用符同时指向同一个对象,当某个引用符退出作用域时,系统就会从栈中删除该引用符。当指向对象的所有引用符都被删除时,对象就被加入垃圾回收的候选名单,垃圾回收器会在适当的时候清除该对象。只要存在指向对象的引用符,对象就不会被清除。.Net 使用的是托管堆,托管堆和传统堆不同,当垃圾回收器清除一个对象后,垃圾回收器会移动堆中其他对象,使它们连续的排列在堆的底部,并用一个堆指针指向空闲空间。当创建新对象时,系统根据堆指针可以直接找到自由的内存空间。使用托管堆时创建对象要快很多,虽然删除对象时整理操作会浪费一定的时间,但这些损失会在其它方面得到很好的补偿。声明一个对象的引用符但却未通过new关键字为其创建一个对象时,引用符中存储的只是空地址。注意记住:引用符是对象在内存中的地址。
4.对象的相等、对象数组、匿名类型、扩展方法
(1)对象的相等
初次看到Object 类的成员你会感到很惊讶,竟然有三个“相等”函数,如果再加上相等运算符“==”的话,就有4 种进行相等比较的方式了。这些方式之间有什么区别呢?
静态的ReferenceEquals()方法,是一个静态方法,用来测试两个引用符是否指向同一个对象,即两个引用符是否包含相同的内存地址。实例版的Equals()方法,在 Object 类中,实例版Equals()方法是一个虚方法,如果未对其重载,只进行引用比较。但我们一般会在派生类中重写该方法以实现值比较。相等运算符==,。默认状态下,若两个对象为值类型,相等运算符“==”比较两个对象的值;若两个对象为引用类型,相等运算符“==”比较两个引用符。但我们可以通过重载运算符的方法加以改变。静态版 Equals()方法的功能与实例版基本一样,实际上静态版Equals()方法通过调用实例版方法进行比较,只是在调用“objA.Equals(objB)”前,会先检查两个参数是否均为空引用,如果均为空引用,返回true,如果一个为空,一个不为空,返回false。总之,一般情况下,我们让 ReferenceEquals()方法进行引用比较,Equals()方法进行值比较,而相等运算符“==”则看情况而定,如果对象看起来像一个值(比如复数类),就把它设计成值比较,如果对象看起来不像值,就把它设计成引用比较。
(2)以对象为元素的数组
例如Cat [] cats=new Cat[5];如此仅是声明了一组引用符而已,并没有真正创建对象。也可以像普通数组那样,声明和初始化同时进行,Cat[] cats = new Cat[]{ new Cat("doraemon,8"), new Cat("Garfield",6) };。
(3)匿名类
有时候某些类在程序中只会使用一次,单独为它编写一个类显得过于啰嗦,这时候用匿名类型就会简洁很多,例如var city = new { Name = "Beijing", ZipCode = 100000 };匿名类型只能由一个或多个公共只读属性组成,不能包含其它种类的类成员。编译时编译器会自动生成相关的类,并创建一个该类的对象以供使用。
(4)扩展方法
很多时候,当需要在已经编译好的类中添加新的功能,但又不想派生该类时,就要以为该类添加扩展方法。
public static ReturnDataTypeX ExtensionMethodName(this ExtendedClass x,DataTypeA y,DataTypeB z,…){}
可在任意命名空间的任意一个静态类中为其它类扩展方法,扩展方法的第一个参数类型是想要扩展的类,必须用this关键字修饰,且扩展方法要定义为静态方法。注意调用扩展方法时,只需要引入扩展方法所在的命名空间,通过被扩展类的实例对象,就可以调用这个扩展方法了。
5. 继承
继承(Inheritance)是软件重用的一种形式,采用这种形式,可以在现有类的基础上添加新的功能,从而创造出新的类。
(1)由基类创建派生类
例如class Mammal:Vertebrata{},这里Mammal 类由Vertebrata 类派生而出,它不光具有自己的新成员,而且继承了Vertebrata 类的所有成员。派生类Mammal 虽然继承了基类Vertebrata 的所有成员,但出于封装性的考虑,在派生类中不能使用基类的私有成员。
(2)protected成员
如果想让类的成员既保持封装性又可以在派生类中使用,那么可以把它定义为protected 成员(受保护成员)。总之private 成员只能在定义它的类中使用,既不能被外界使用,也不能被派生类使用;而protected 成员虽然不能被外界使用,但可以被派生类使用。
(3)间接继承
例如class Mammal:Vertebrata{}、class Human : Mammal{},那么Human就间接继承了Vertebrata的所有非私有成员。
(4)虚方法的重写
考虑到派生类的某些方法虽然与基本同名,但却想赋与其不同的方法内涵,这时就可以把基类的这些方法设计为虚方法,然后在派生类中重写(Override)该方法。虚方法用virtual声明。例如:
class Vertebrata { public virtual void Breathe() { Console.WriteLine("呼吸");}…}
class Mammal : Vertebrata { public override void Breathe() { Console.WriteLine("肺呼吸"); }…}
class Fish : Vertebrata{public override void Breathe(){Console.WriteLine("鳃呼吸");}…}
有三个版本的Breathe()方法,当对象属于Vertebrata 类时,会调用Vertebrata
类中的Breathe()方法;当对象属于Mammal 类时,会调用Mammal 类中的Breathe()方法;当对象属于Fish 类时,会调用Fish 类中的Breathe()方法。总之,系统会根据对象的实际类型调用相应版本的方法。
不但可以重写方法,也可以重写属性,但静态方法不能重写。重写和重载的区别,很多读者一直不清楚方法重载和方法重写的区别,其实重载和重写的区别很简单。重
载是指同一个类中有若干个名称相同但参数不同的方法,调用方法时,系统会根据实参情况,调用参数完全匹配的那个方法。重写是指在继承关系中,在派生类中重写由基类继承来的方法,这时基类和派生类中就有两个同名的方法,系统根据对象的实际类型调用相应版本的方法,当对象类型为基类时,系统调用基类中的方法,当对象类型为派生类时,系统调用派生类中被重写的方法,这其实体现了类的多态性。重载方法发生在同一个类中的同名方法之间,重写方法发生在基类和派生类之间。
(5)隐藏基类非virtual的普通方法
由于只能重写基类中的虚方法,不能重写普通方法。要想在派生类中修改基类的普通方法,需要用new 关键字隐藏基类中的方法。例如public new void Sleep(){…},如此可以隐藏父类的同名且非virtual方法。
(6)base关键字
如何在派生类中调用被重写或隐藏的基类方法呢?我们知道,每个对象都可以用this关键字引用自身的成员,同样,也可以用base关键字引用基类的成员。
(7)抽象类和抽象方法
没有实例化意义的类,例如脊椎动物只是一个抽象概念,每一种脊椎动物要么是鱼类,要么是鸟类,要么是哺乳动物等,这样的类可以设计成抽象类,抽象类不能被实例化,只能作为其它类的基类存在,其目的是抽象出子类的公共部分以减少代码重复。抽象类用关键字abstract声明。不光可以声明抽象类,还可以声明抽象方法。抽象方法是一种特殊的虚方法,它只能定义在抽象类中,抽象方法没有任何执行代码,必须在派生类中用重写的方式具体实现(如果忘记重写抽象方法的话,会出现编译错误)。除了抽象方法外,我们还可以在抽象类中定义抽象属性(Abstract property)。抽象属性也没有具体实现代码,必须在派生类中重写。
(8)密封类和密封方法
密封类(Sealed class)是一种不能被继承的类,用sealed关键字声明。同样,如果想防止一个方法被派生类重写,可以用sealed override关键字把它为声明密封方法,例如public sealed override void F()。
(9)派生类的构造函数
创建对象时,系统先调用基类的构造函数,初始化基类的变量,然后调用派生类的构造函数,初始化派生类的变量,是一个由基类向派生类逐步构建的过程。删除对象时,先调用派生类的析构函数,销毁派生类的变量,然后调用基类的析构函数,销毁基类的变量,是一个由派生类向基类逐步销毁的过程。
因为派生类不能使用基类的私有变量,所以不能通过派生类的构造函数直接初始化基类的私有变量,但我们可以通过显式调用基类的构造函数实现。例如:public Human(string nameValue,int ageValue):base(ageValue)。一般情况下尽量用基类的构造函数初始化基类的成员。
(10)万类之源Object
C#中所有的类都直接或间接继承于Object 类,C#专门设计了object 关键字,它相当于Object 的别名,该类定义了以下方法:
6.多态性
对象存储在堆中,引用符存储在栈中,引用符的值是对象在堆中的地址,因此通过引用符可以轻松的找到对象,一般情况下,引用符和对象属于同一类型,基类的引用指向基类的对象,派生类的引用指向派生类的对象。基类引用符可以指向派生类对象,但派生类的引用符不能指向基类的对象。继承和多态性是开发复杂软件的关键技术。可以定义基类的引用符,针对此引用符调用基类的某些方法,后续通过不同的派生类对象“赋值”给这个引用符,从而在调用这个基类引用符方法时,使其实际调用派生类对象的不同方法,从而方法的调用呈现多态性。
is引用符,通过它用来判断对象是否与给定的类型兼容(属于该类型或者属于该类型的子类)。所有派生类的对象都可以看作基类的对象。
向上或向下类型转换,由派生类转换为基类,即向上的类型转换是自动进行的,但转换后,基类的引用符不能引用派生类特有的成品。由基类向派生类转换的过程为向下类型转换,这时需要强制向下转换,由于仅当由基类向派生类强制转换才能成功,不然程序就会抛出异常,因此,转换前,应当使用is运算符进行检查,类型是否兼容,强制转换可以通过as运算符实现,例如Vertebrata someone=new Human(); Human people=someone as Human; as转换是执行两个引用类型间的显式转换,是一种安全的转换,使用前不需要使用is运行符测试类型,当类型不兼容时,转换的结果是null,而不会抛出异常,因此通常建议采用as进行类型转换。
7.接口
世界很多组织致力于标准化工作,标准化可以增强产品的通用性,当然也可增强软件的通用性,使软件使用更加方便。在软件领域,实现标准化的一种方法是制定统一的接口(Interface)。接口只规定系统具有哪些成员以及每个成员的功能,具体如何实现由各个公司自己设计,也就是说接口为大家制定了一个规范,然后各公司具体实现这个规范。接口用关键字interface 定义,接口的名称习惯上以字母I 开头。一般情况下,接口中只能包含成员的声明,不能有任何实现代码。接口的成员总是公有的,不需要也不能添加public 等修饰符,也不能声明为虚方法或静态方法。注意,实现接口的类的相应成员必须添加public 修饰,并且可以声明为虚方法。
(1)接口的继承
接口继承方式与类相同,接口跟接口实现类的关系与基类与派生类的关系一样,因此通过使用接口声明对象,也可以很好地使接口声明对象呈现出多态性。
(2)接口的多继承和显式实现
C#中,类与类之间是单继承的,即一个类只能继承一个基类,但类与接口间是多继承的,一个类可以实现多个接口。如果一个类继承了多个接口而这些接口又有重名的成员,就必须用到接口的显式实现,即通过在接口实现类中如此显式实现:
interface IAnimal{ void Breathe();}
interface IPlant{ void Breathe();}
class Life:IAnimal,IPlant
{ void IAnimal.Breathe(){…}
void IPlant.Breathe(){…}}
可以看出,显式实现就在方法属性前加上相应的接口名即可。