第2章 封装
面向对象编程的三大基本特征:封装、继承和多态。封装(Encapsulation)有时称为面向对象编程的第一支柱或原则。根据封装原则,类或结构可以指定自己的每个成员对外部代码的可访问性,可以隐藏不得在类或程序集外部使用的方法和变量,以限制编码错误或恶意攻击发生的可能性。本章主要介绍封装、方法重载以及构造函数的概念、定义与使用。
2.1 封装的概念
封装,顾名思义,就是密封包装起来。封装广泛应用于各个行业各个领域,例如计算机中央处理器(CPU)、硬盘等配件,都是封装后的配件。对于台式计算机,只要计算机的鼠标、键盘、显示器等对外接口不变,不管计算机内部的CPU、内存条、主板、硬盘等技术如何升级,都可以组装起来,正常使用,通过计算机主机箱封装计算机主机。如果把汽车看成一个封装对象,驾驶员只能通过方向盘和仪表来操作汽车,而汽车的内部实现则被隐藏起来。在日常生活中,为什么要把某些事情隐藏封装起来呢?一是,有些东西是很关键很机密的,不想随便被使用、被改变、被访问。二是,可能这个东西不是很关键机密,访问和改变也无所谓,但是,封装起来后使用者不会再有了解其内部的欲望,使处理问题变得更加简单。封装是人们在现实世界中解决问题时,为了简化问题,对研究的对象所采用的一种方法,一种信息屏蔽技术。
面向对象编程中的封装(Encapsulation)就是通过定义类,并且给类的成员(变量、字段、属性、方法等)加上访问控制(public,private,protected),把只需在本地类中使用的成员变为私有,拒绝他人访问;把需要公开的属性和方法变为公有,供他人访问;并尽可能对外部隐藏类的内部实现细节。对外界其他类或对象来说,不需要了解类内部是如何实现的,只需要了解类所呈现出来的外部行为即可。一个类或对象不能直接操作另一个类或对象的内部数据,所有的交流必须通过公开的属性、方法来调用。就像.NET类库中的类,比如String类、Math类、DateTime类,人们不知道其类内部的实现代码,只知道调用方法就可以了。所以,通过封装这个手段,就抽象出来了事物的本质特性,也是一种良好的编程习惯和规范。
封装隐藏某个对象的与其基本特性没有很大关系的所有详细信息,就是将需要让其他类知道的公开出来,不需要让其他类了解的全部隐藏起来。封装可以阻止对不需要信息的访问,可以使用访问修饰符实现封装,也可以使用方法实现封装,可以将隐藏的信息作为参数或者属性值、字段值传给公共的接口或方法,以实现隐藏起来的信息和公开信息的交互。封装的目的就是实现“高内聚,低耦合”。高内聚就是类的内部数据操作细节自己完成,不允许外部干涉,就是这个类只完成自己的功能,不需要外部参与;低耦合就是仅暴露很少的方法给外部使用。面向对象程序设计的封装,隐藏了某一方法的具体执行步骤,取而代之的是通过消息传递机制传送消息给它。
在面向对象编程中,封装的目的是增强安全性和简化编程。使用封装的作用如下。
1)将相关联的变量和方法封装成一个类,变量描述类的属性,方法描述类的行为,这符合人们对客观世界的认识。
2)封装使类形成两部分,接口(可见)和实现(不可见),将类所声明的功能与内部实现细节分离。
3)只能通过属性、方法访问类中的数据,保护对象,避免用户误用,提高了程序的安全性。
3)类与创建对象及调用对象的程序代码相互独立,修改类中的代码时,不用修改其他代码,从而可以让程序代码更容易维护,提高了模块的独立性。一个好的封装可以在以后代码功能发生改变的时候,只需在封装的地方修改即可,不需要大范围内修改。
4)便于调用者调用,调用者不需要知道该功能是如何实现的,只需要知道调用该接口或方法能实现该功能。
5)隐藏复杂性,降低了软件系统开发难度,易开发;各模块独立组件修改方便,重用性好,易维护。
6)封装隐藏了类的内部实现机制,从而可以在不影响使用的前提下改变类的内部结构,同时保护了数据。
【例2-1】下面程序在创建的对象student中,为了访问Student类中的age,在定义Student类中必须把age的访问控制定义为public,这是没有采用封装的代码。
修改上面的代码,把age的访问控制定义为private,把这个成员变量封装起来,使其不能被外部访问,而是通过两个方法与类的外部联系。代码如下。
通过封装可以隐藏类的实现细节,将类的状态信息隐藏在类内部,不允许外部程序直接访问类的内部信息,而是通过该类所提供的公开的属性和方法来实现对内部信息的操作访问。
封装时会用到多个访问修饰符来修饰类和类成员,赋予不同的访问权限。而为了保护类中字段数据的安全性,使用类的属性来限制外界对类对象数据的访问和更新操作。
在程序上,隐藏对象的属性和实现细节,仅对外公开接口,控制在程序中属性的读和修改的访问级别;将抽象得到的数据和行为(或功能)相结合,形成一个有机的整体,也就是将数据与操作数据的源代码进行有机结合,形成“类”,其中数据和方法都是类的成员。
2.2 类的属性
属性(property)是对象的性质与对象之间关系的统称。在面向对象的编程和思想中,属性与字段相似,都是类的成员,都有类型,可以被赋值和读取。通常把字段定义为私有的,然后再定义一个与该字段对应的,可以读、写的属性。因此,属性更充分地体现了对象的封装性。怎样封装?具体的封装就是将字段私有化,提供公有的方法访问私有的字段。
在【例2-1】中,为了隐藏成员变量age,引入了GetAge()、SetAge()方法,来获得和设置被隐藏的成员变量age,这样做的缺点是需要编写许多方法。为了解决这个问题,在.NET中提供了属性,以方便地封装字段。
2.2.1 属性的声明
如何实现封装?其实就是封装字段,C#属性是对类中字段的保护,像访问字段一样来访问属性,同时也就封装了类中的内部数据。在C#中,类的属性是在一个类中采用下面方式定义的类成员。在声明类中,定义属性的语法格式如下。
由于字段要私有化,定义字段的访问修饰符为private,这样字段才能被隐藏。由于属性要公开,在封装属性的get和set方法中,访问修饰符要声明为public,才能在其他类中访问到。字段是在类或结构中直接声明的任意类型的变量,字段是其包含类型的成员。
get、set称为属性访问器,get和set访问器有预定义的语法格式,可以把属性访问器理解为方法。
set访问器负责设置数据,set访问器总是拥有一个单独的、隐式的值参数,名称为value,value表示调用属性时给属性赋的值,与属性的类型相同,在set访问器内部可以像普通变量一样使用value,其返回类型是void。
get访问器负责获取数据,get访问器没有参数,拥有一个与属性类型相同的返回值。get访问器最后必须执行一条return语句,返回一个与属性类型相同的值。
set访问器和get访问器可以以任何顺序声明,除此之外,属性不允许有其他方法。
在封装属性代码中,同时有get和set方法,则该属性称为读写属性;如果只有set方法,而没有get方法,则该属性称为只写属性;如果只有get方法,而没有set方法,则该属性称为只读属性。
C#属性是对类中的字段的保护,像访问字段一样来访问属性。同时,也就封装了类的内部数据。属性的特点是每当赋值运算的时候自动调用set方法,其他时候则调用get方法。
【例2-2】把【例2-1】中的代码,采用属性来实现。
新建一个项目,项目名称为FZ2。在“项目”菜单中单击“添加类”,或者在“解决方案资源管理器”中右击项目名称FZ2,单击“添加”→“类”。显示“添加新项”对话框,在“名称”框中改写类名为Student.cs,然后单击“添加”按钮。进入Student.cs编辑标签,在class Student前添加访问修饰符public。在定义Student类中添加age字段,访问修饰符为private。
在Visual Studio中,有两种封装字段的方法,一种是手工输入封装字段代码;一种是通过简单操作,让Visual Studio自动生成封装字段代码。下面采用自动生成的方法。右击要封装字段的代码行,在快捷菜单中单击“重构”→“封装字段”,如图2-1所示。显示“封装字段”对话框,如图2-2所示,在“属性名”文本框中系统自动命名属性名,且为Pascal命名法,一般不需要更改,直接单击“确定”按钮。
图2-1 字段代码行的快捷菜单
图2-2 “封装字段”对话框
显示“预览引用更改-封装字段”对话框,如图2-3所示,直接单击“应用”按钮,则属性代码添加到编辑区,如图2-4所示。
图2-3 “预览引用更改-封装字段”对话框
图2-4 添加的属性代码
通过上面的操作,系统生成的Age属性的封装代码如下。
字段变量推荐采用cancel命名法,例如age、studentName;属性采用Pascal命名法,例如Age、StudentName。
get{return age;}方法用于获得age,通过return age返回age的值,实现获得Age属性值的功能。
set{age=value;}方法用于设置age,其中age=value中的value表示调用属性时给属性赋的值(或称传入的值),然后通过赋值语句age=value把属性值赋值给age,实现改变Age属性值的功能。
如果要在set{age=value;}方法中实现更多的功能,可以在set方法中修改代码。
采用封装属性来实现设置年龄和获得年龄的完整代码如下。
从以上例子中的代码可以看到,属性在外观和功能上都类似于字段。但字段是数据成员,属性是特殊的方法成员,因此存在以下一些特定的局限:
1)不能使用set访问器初始化一个class或者struct的属性。
2)在一个属性封装中最多只能包含一个get访问器和一个set访问器,属性封装中不能包含其他方法、字段或属性。
3)get和set访问器不能带有任何参数,所赋的值会使用value变量自动传给set访问器。
4)不能声明const或者readonly属性。
2.2.2 属性的访问
属性的访问很简单,带有set访问器的属性可以直接通过如下格式赋值:
对象.属性=值或变量;
例如:
student.Age=18;
带有get访问器的属性可以通过如下格式得到其值:
变量=对象.属性;
例如:
int age=student.Age;
2.3 方法重载
在面向对象的高级语言中,方法重载(overload)是指在同一个类中,定义多个方法名相同,但是方法的参数个数、次序、类型不同的方法;调用方法时,根据实参的形式,编译器就可以选择与其匹配的方法执行操作的一种技术。方法重载没有关键字,适用于普通方法和构造函数。
重载对返回值没有要求,可以相同,也可以不同。但是如果方法名相同,参数个数、次序、类型都相同,而返回值不同,则无法构成重载。
决定方法是否构成方法重载有几个条件规则:①在同一个类中;②方法名相同;③参数数量不同;④参数的顺序不同;⑤参数的数据类型不同。
【例2-3】设计一个控制台的两个或3个整数、双精度的加法程序,加法方法采用方法重载。设计思路是定义一个加法类Adder,在加法类中分别定义两个、3个整数的加法方法,定义两个、3个双精度的加法方法。
加法类Adder的代码如下。
在class Program类的Main()方法中,实例化加法类,用创建的加法对象adder调用不同参数的加法方法,通过方法重载实现计算,代码如下。
Adder adder=new Adder();//实例化类Adder,创建adder对象
Console.WriteLine(adder.Add());//调用无参方法,并显示
Console.WriteLine(adder.Add(2,3));//调用两个整数的方法,并显示
Console.WriteLine(adder.Add(2,3,5));//调用3个整数的方法,并显示
Console.WriteLine(adder.Add(1.2,5.6));//调用两个双精度数的方法,并显示
Console.WriteLine(adder.Add(2.1,3.3,6.2));//调用3个双精度数的方法,并显示 在Adder类的声明中,Add()方法重载了5次,程序运行结果如图2-5所示。想一想,为什么Adder类声明中的“public string Add()//方法重载5”与“public int Add()//不构成方法重载”不能构成方法重载?
【课堂练习2-1】指出下面方法定义代码中,哪些方法可以构成方法重载?哪些不能构成方法重载?为什么?
图2-5 加法程序运行结果
【例2-4】使用方法重载实现给小狗、小鸟等不同宠物看病的功能。
1)分别定义Dog和Bird两种不同动物的类,都有3个属性,代码如下。
2)定义一个宠物医生类Doctor,在Doctor类中定义两个方法,分别给这两种动物看病。在同一个Doctor类中,定义了两个叫Cure()的方法,两个Cure()方法的方法名相同,返回值类型也相同,但是它们的参数类型是不同的。给小狗看病的Cure()方法的参数类型是Dog类型;给小鸟看病的Cure()方法的参数类型是Bird类型。具体代码如下。
3)先看医生给小狗看病的代码,实例化了一个医生对象doc,实例化了一个小狗对象,调用医生对象的Cure(dog)方法,参数是dog。代码如下。
按<Ctrl+F5>键执行程序,显示如图2-6所示,医生的治疗方法是针对小狗的“打针,吃药”。
图2-6 小狗的治疗方法
4)下面编写给小鸟看病的代码。实例化一个bird对象,将bird对象作为参数传递给Cure(bird)的方法。代码如下。
按<Ctrl+F5>键执行程序,显示如图2-7所示,医生的治疗方法就变成针对小鸟的“吃药,疗养”。
图2-7 小鸟的治疗方法
这样就在同一个类中,使用一个医生对象doc的相同名字的方法Cure(),实现了给小狗、小鸟看病的功能,但是两次调用时由于传递的参数不一样,得到了不同的结果,这里用到了方法的重载。
2.4 构造函数
C#提供了更好的机制来增强程序的安全性,C#编译器具有严格的类型安全检查功能,它几乎能找出程序中所有的语法问题,但是程序通过了编译检查并不表示错误已经不存在了,不少难以察觉的程序错误是由于变量没有被正确初始化造成的,而初始化工作很容易被程序员遗忘。C#语言把对象的初始化工作放在构造函数中,当对象被创建时,构造函数被自动执行,这样就不用担心忘记对象的初始化工作。
2.4.1 构造函数的概念
构造函数又叫构造方法、构造器,它是类的一种特殊的成员函数,在创建类的新对象时执行。它主要用于为对象分配存储空间,主要用来在创建对象时初始化对象,即为对象成员变量赋初始值,总与new运算符一起使用在创建对象的语句中。一个类可以有多个构造函数,可根据其参数个数的不同或参数类型的不同来区分它们,即构造函数的重载。
当创建一个对象时,对象表示一个实体。例如,下面代码创建了一个学生对象,那么该学生就应该有名字、年龄等数据成员,所以创建对象后必须给该对象的数据成员赋初始值。
Student st=new Student();
st.Name="张三丰";//设置属性值,使该对象的Name值为"张三丰"
st.Age=18;//设置属性值,使该对象的Age值为18
如果创建了该学生的对象,但并没有给它的数据成员初始化,则该学生的名字、年龄等数据成员的值是系统默认的值(根据对象属性的数据类型决定,例如int型为0,string型为null),这时这个对象就没有意义。因此,当创建对象时,经常需要自动地做某些初始化的工作,例如初始化对象的数据成员。自动初始化对象的工作由该类的构造函数来完成。
构造函数是C#类中一个特殊的public成员方法,与普通方法相比,构造函数是被自动调用的。任何时候只要创建类,就会调用类的构造函数。构造函数的作用是在创建对象时,系统自动调用它来初始化新对象的数据成员。构造函数使得程序员可设置默认值、限制实例化以及编写灵活且便于阅读的代码。
2.4.2 构造函数的定义
每个类都必须至少有一个构造函数。构造函数的语法格式如下。
构造函数是类的一个特殊的成员函数,除了具有一般成员函数的特点外,还有独有的特点。在定义构造函数时有以下说明。
1)构造函数的命名必须和类名完全相同。在C#中普通方法名不能与构造函数同名。一个类中可以有多个构造函数,所有构造函数的名称必须相同,它们由不同的参数区分,系统在自动调用时按函数重载的规则选一个执行,即构造函数可以重载,从而提供初始化类对象的不同方法。
2)构造函数的功能是对对象初始化,因此在构造函数中只能对数据成员初始化。这些数据成员一般为私有成员,在构造函数中一般不做初始化以外的事情。
3)构造函数没有返回值,因此也没有返回类型,也不能用void来修饰。它可以带参数,也可以不带参数。
4)构造函数必须通过new运算符在创建对象时自动调用,当创建对象时,该对象所属的类的构造函数自动被调用,在该对象生存期中只调用这一次,调用哪一个构造函数取决于传递给它的参数类型。每创建一个对象,就调用一次构造函数。构造函数不需要被程序员显式调用,也不能被程序员调用。
5)若在定义类时未定义任何构造函数,系统会提供一个默认的,不带参数的构造函数,此构造函数的函数体为空。
6)构造函数不能被继承。
2.4.3 构造函数的分类
构造函数的主要作用是在创建对象时初始化对象,在一个类的声明中至少要有一个构造函数。构造函数分为默认构造函数和自定义构造函数。
1.默认构造函数
在默认情况下,也就是在类的声明中没有写构造函数的定义代码,则C#编译器将自动创建一个无参数的构造函数,这个构造函数称为默认构造函数。默认的构造函数没有参数,与类同名。默认构造函数自动实例化对象,它只能把对象的所有成员变量初始化为其成员变量类型的默认值(例如,引用类型为空引用,数值类型为0,bool为false)。
如果在类的声明中提供了带参数的构造函数,则C#编译器就不会自动提供无参数构造函数。为了避免调用无参构造函数时出错,需要程序员手工编写一个无参数无语句的构造函数。
2.自定义构造函数
如果在声明类中,程序员也可以创建无参数和有参数的构造函数,称为非默认的构造函数,或者自定义构造函数。
在声明类中,只要程序员定义了自定义构造函数(有参数、无参数),则默认的无参构造函数就不再自动创建。这时,如果希望能够调用不带参数的构造函数,程序员就必须显式地声明一个无参数的构造函数。
如果类中显式地定义了有参构造函数,而没有显式地定义无参构造函数,则在初始化对象时试图调用无参构造函数,将会发生编译错误。只有当类中没有自定义构造函数时(此时调用默认构造函数),或者类中有自定义的无参构造函数时,才能在调用构造函数时不提供实参。
2.4.4 调用构造函数
在声明类时,一个类中会包含或默认构造函数,也可能包含自定义构造函数(无参或有参构造函数)。
1.调用默认构造函数
默认构造函数不带参数,只要使用new运算符实例化对象,并且不为new提供参数,就会调用默认构造函数。假设一个类包含有默认构造函数,则调用默认构造函数的语法如下。
类名 对象名=new类名();
【例2-5】调用默认构造函数。在类中没有定义任何构造函数,如下代码。
由于在声明的类中没有定义构造函数,系统自动创建默认无参构造函数,成员变量的值初始化为系统默认的值。执行程序,运行结果如图2-8所示。
2.调用自定义构造函数
自定义构造函数包括无参和有参构造函数。
(1)调用自定义无参构造函数
自定义无参构造函数的调用与默认构造函数的调用相同。
【例2-6】调用无参构造函数。在自定义无参构造函数中,给类的成员变量赋默认值。
图2-8 调用默认构造函数
程序运行结果如图2-9所示,自定义无参构造函数已经为对象初始化。
图2-9 自定义无参构造函数
(2)调用有参构造函数
如果在类的声明中包含有参构造函数,调用这种有参构造函数的语法如下。
类名 对象名=new类名(参数表);
参数表中的参数可以是变量、常量或表达式,参数之间用逗号分隔。在一个类中可以有多个有参构造函数,所以实例化类的对象时也可提供不同的初始值,构成构造函数的重载。
【例2-7】调用带参数的构造函数示例,如下代码。
如果取消Student stu0=new Student()前的注释,运行程序,看看出现什么问题?为什么?如何消除这个错误?
2.4.5 构造函数的重载
构造函数与方法一样都可以重载。构造函数的重载是指构造函数具有相同的名字,而参数的个数或参数类型不相同。构造函数是重载方法的典型应用,在这里又叫重载构造函数。重载构造函数的主要目的是为了给创建对象提供更大的灵活性,以满足创建对象时的不同需要。C#中有默认构造函数,也可以定义多个带参数的构造函数。构造函数必须与类同名,并且不能有返回值。所以C#构造函数重载相当于不同数量的参数方法重载,例如,可以传0个参数也可以传2个,也可以传3个参数或更多。构造函数重载的规则与方法重载的规则相同。
【例2-8】构造函数的重载示例。新建Department类,添加几个字段,并通过属性对字段进行封装。添加几个带参数构造函数,显式添加默认构造函数形式的无参构造函数。代码如下。
在class Program类的Main()中,新建Department类,实例化Department对象,调用不同的构造函数,输出不同信息。代码如下。
图2-10 重载构造函数
程序运行结果如图2-10所示,通过重载构造函数得到不同的结果。
2.5 习题
一、选择题
1.属性从读、写特性上分类,可以划分为3种,不包括( )。
A.只读属性 B.只写属性
C.读、写属性 D.不可读不可写的属性
2.以下关于属性的描述,正确的是( )。
A.属性是以public关键字修饰的字段,以public关键字修饰的字段也可称为属性
B.属性是访问字段值的一种灵活机制,更好地实现了数据的封装和隐藏
C.要定义只读属性,只需在属性名前加上readonly关键字即可
D.在C#的类中不能自定义属性
3.下面MyClass类中的属性name属于( )属性。
A.只读 B.只写 C.可读可写 D.不可读不可写
4.以下关于方法重载的说法,正确的是( )。
A.如果两个方法名称不同,而参数的个数不同,那么它们可以构成方法重载
B.如果两个方法名称相同,而返回值的数据类型不同,那么它们可以构成方法重载
C.如果两个方法名称相同,而参数的数据类型不同,那么它们可以构成方法重载
D.如果两个方法名称相同,而参数的个数相同,那么它们一定不能构成方法重载
5.以下( )不是构造函数的特征。
A.构造函数的函数名和类名相同 B.构造函数可以重载
C.构造函数可以带有参数 D.可以指定构造函数的返回值
6.指出下面方法定义代码中,构成方法重载的方法为( )。
A.方法2、3 B.方法1、2、4
C.方法1、2、3 D.方法2、3、4
二、编程题
1.在Box类中,定义3个字段length、width、height,然后封装字段,得到其属性。定义一个无参的计算体积的方法volume(),在方法中由字段计算体积。定义一个无参构造函数,定义一个有参构造函数。对于无参构造函数,长方体的length、width、height默认值0;对于有参构造函数,构造函数的参数为整型。在Main()方法中,创建3个对象,分别实例化无参构造函数、有参构造函数,通过调用计算体积的方法volume(),输出其值。
2.实现计算器的加、减功能。
1)分别声明加法类、减法类和计算类,在计算类中定义加法方法、减法方法,然后在主程序中通过实参实现加法、减法运算。
2)在1)的基础上,改写计算类,在计算类中采用方法重载来定义加法、减法方法,然后在主程序中通过实参实现加法、减法运算。