C++ 第六章 面向对象编程(一)

ref 2

前言

程序 = 数据 + 算法

变量是程序中的数据元素,函数是程序中的算法元素

面向对象程序设计将程序中的数据元素和算法元素根据内在关联关系进行分类管理,这就形成了”类“的概念,分类可以更好的管理

类相当于是一种自定义数据类型,用类所定义的变量称为”对象“

基于类编写程序可以更好的组织管理程序代码,也更便于代码重用,在团队分工开发模式中,某些程序员定义类,编写类代码,某些程序员使用类,定义对象,然后通过对象,重用别人的类代码,

本章内容共计7节。

前言
一、面向对象程序设计方法
    1.1 封装的概念:
    1.2 类的接口
    1.3 面向对象程序的设计过程
二、类的定义
    2.1 定义类的语法
    2.2 数据成员的语法细则
    2.3 函数成员的语法细则
    2.4 访问权限的语法细则
三、对象的定义和访问
    3.1 类的定义和访问
    3.2 对象指针即指向运算符
    3.3 类与对象的编译原理
四、对象的结构与析构
    4.1 变量在内存中的生存期
    4.2 参与对象的构造和析构的过程
        1、构造函数:
        2、析构函数
        3、拷贝函数中的深拷贝与浅拷贝
    4.3 类和对象编程的主要内容
五、对象的应用
    5.1 对象数组
        1、定义对象数组的语法:
        2、对象数组的访问:
    5.2 对象的动态分配
    5.3 对象在函数中的应用
    5.4 函数间的参数传递
        1、值传递与常对象
        2、引用传递与常引用
        3、指针传递与指向常对象的指针
六、类中的常成员与静态成员
    6.1 常数据成员
    6.2 常函数成员
    6.3 静态数据成员
    6.4 静态函数成员
    6.5 总结
七、类的友元
    7.1 友元函数
    7.2 友元类

一、面向对象程序设计方法

从结构化设计到面向对象设计

程序设计任务:编写一个计算圆形、长方形面积和周长的C++演示程序,程序由甲乙2位程序员分工协作,共同编写。使用结构化程序设计方法,将程序划分为5个函数。甲负责编写主函数,乙负责编写计算面积、周长的子函数。

采用结构化程序设计:数据分散管理策略

/* 主函数 */
#include <iostream>
using namespace std;

/* 声明函数原型 */
double Clen(double r);
double Carea(double r);
double Rlen(double x, double y);
double Rarea(double x, double y);

int main()
{
    double r,a,b;
    cout<<"请输入半径:";
    cin>>r;
    cout<<endl;
    cout<<"请输入长宽";
    cin>>a>>b;
    cout<<"圆形的面积为:"<<Carea(r)<<"周长为:"<<Clen(r)<<endl;
    cout<<"长方形形的面积为:"<<Rarea(a,b)<<"长方形的周长为:"<<Rlen(a,b)<<endl;    
    return 0;
}
/* 子函数 */
double Clen(double r)
{ return (3.14*2*r); }

double Carea(double r)
{ return(3.14*r*r); }

double Rlen(double x, double y)
{ return((x+y)*2); }

double Rarea(double x, double y)
{ return(x*y); }

采用面向对象程序设计:

/*主函数*/
#include <iostream>
using namespace std;

/* 声明类 */
class Circle
{
    public:
        double r; //半径,数据成员
        double Carea(); //求面积,函数成员
        double Clen(); //求周长,函数成员
};
class Rectangle
{
    public:
        double a,b; //长宽,数据成员
        double Rarea(); //求面积,函数成员
        double Rlen(); //求周长,函数成员
};

int main()
{
    Circle obj1;//定义圆形类的对象obj.1
    Rectangle obj2;//定义长方形类对象obj2
    
    cout<<"请输入半径:";
    cin>>obj1.r; //获取半径
    cout<<endl;
    cout<<"请输入长宽"; //获取长宽
    cin>>obj2.a>>obj2.b;
    cout<<"圆形的面积为:"<<obj1.Carea()<<",周长为:"<<obj1.Clen()<<endl;
    cout<<"长方形形的面积为:"<<obj2.Rarea()<<",长方形的周长为:"<<obj2.Rlen()<<endl;    
    return 0;
}
/* 定义类 */
class Circle //圆形类声明
{
    public:
        double r; //半径,数据成员
        double Carea(); //求面积,函数成员
        double Clen(); //求周长,函数成员
};
//圆形类,实现函数成员的具体定义。
double Circle::Carea()
{ return(3.14*r*r); }
double Circle::Clen()
{ return (3.14*2*r); }

class Rectangle
{
    public:
        double a,b;
        double Rarea();
        double Rlen();
};
double Rectangle::Rarea()
{ return(a*b); }
double Rectangle::Rlen()
{ return ((a+b)*2); }

类代码更便于管理,在大型程序开发中,程序员需要定义大量变量和函数,这时用类来组织代码的优势也更加明显,例如,为了开发Windows应用程序,微软为程序员提供了2000多个函数,这些函数统称为win32 API,如何组织管理这些函数是非常麻烦的问题,甚至为这些函数命名也是一件麻烦事儿,因为这些函数不能重名,可以将这些函数按功能划分为不同的类,不同类中的函数可以重名,即便于组织代码,也便于程序员理解记忆。

使用其他文件中的类,需要先声明再使用,类的声明形式,与定义类时的声明部分相同,可以直接复制过来。声明类的代码同样可以写入头文件当中,以简化代码,在类的实现和使用时,直接使用#include指令包含对应的头文件即可。

1.1 封装的概念:

封装是与代码重用相关得一个概念,代码重用是提高软件开发效率得重要手段,也是软件技术不断进步得主要动力,代码重用过程中,程序员有2类角色,一类是提供代码的程序员,一类是使用代码的程序员。可重用的代码相当于是一种程序零件,可提供给不同得程序员组装出不同的产品,提供程序代码的程序员相当于是程序供应商

  1. 提供代码的程序员

    将相对独立、能广泛使用的程序功能提炼出来,编写成函数或类等形式的可重用代码。可重用代码的特点是”一次开发,长期使用“。编写重用代码程序员的性格是”幸苦自己,方便别人“,因此只幸苦一次。该角色设计的程序代码越强大越好,越通用越好,这样才能被更多的人使用。

  2. 使用代码的程序员

    站在使用代码的程序员的角度,他希望能够使用别人提供的程序零件,组装自己的程序,这样能够站在更高的起点上,开发功能更强的产品,他希望程序零件能够承担更多的功能,从而降低开发工作量,另外希望使用越方便越好,图省事儿,图方便,例如,头文件就是为了方便程序员声明别人编写的函数或类而引入的语法形式。

c++语言可以将只在内部使用的成员封装起来,以防止它们被误访问,类的封装有2层含义

  1. 开放

    在定义类的时候,将必须被外部访问的成员开放出来,以保证类的功能,可以被正常使用,

  2. 隐藏

    在定义类的时候,将必须不需要被外部访问的成员隐藏起来,以防止它们被误访问,被隐藏的成员只在内部使用,能被类里的其他成员访问。但不能被外部其他函数访问,

c++语言中类的封装,是在类定义的时候,为类成员声明不同的访问权限来实现的。访问权限有3种:

  1. 公有权限(public)

    被赋予公用权限的类成员是开发的,称为公有成员

  2. 私有权限(private)

    被赋予私有权限的成员将被隐藏,称为私有成员

  3. 保护权限(protected)

    被赋予保护权限的成员是半开放的,称为保护成员

访问权限,是编写类的程序员,为控制实用类的程序的使用操作,保障正确访问类成员而设定的,

1.2 类的接口

公有成员是封装后类对外的接口,一个类必须有公有成员,否则这个类无法使用

程序员设计类时应根据功能要求合理设定成员权限,一方面要开发用户正常使用所必须的成员,另一方面要尽可能隐藏不需要直接访问的成员。

1.3 面向对象程序的设计过程

面向对象的程序设计过程,可以简单的分为3个阶段,分别是:分析、抽象和组装,通常以uml统一建模语言来描述设计结果,并编写出书面的程序设计报告。

类的设计:将客观事物归纳,划分成不同的类,是人类解决客观事件,常用的方法,分类就是抓住主要特征,忽略次要特征,将具有功能性的事务,划分成一类,分类的过程,是一个不断抽象的过程,面向对象程序设计将分类称为抽象,计算机程序处理任何事务,都是将事务转换成数据模型,对事务的处理,就是对数据的处理。面向对象程序设计,将一个个具体的事物称为对象,对事物进行规划,抽象所划分成的类别,称为类;数据被称为事物的属性,在程序中,用变量存储数据;处理数据的算法,被称为方法,在程序中用函数来描述;同类对象具有相同的属性和方法,这些属性和方法被划分成类的成员,程序员需分析,提炼出类的属性成员和方法成员,并合理设定各成员的访问权限,这就是类的设计。

一个类要提炼几个属性和方法,要根据具体功能来定。面向对象的分类设计过程是一个自底向上,逐步抽象的过程。从一个个具体的对象,可以抽象出小类,从小类可以抽象出更大的类,越往上类越宽泛、越抽象。c++语言支持面向对象程序设计,用类的语法形式来描述类图。

二、类的定义

定义一个类,就是用c++语言描述该类包含哪些数据成员、函数成员、以及各成员的访问权限

2.1 定义类的语法

/* 类声明(declaration)部分 */
class 类名
{
    public:
        公有成员
    protected:
        保护成员
    private:
        私有成员
};
/* 函数成员的完整定义,类实现(implementation)部分 */
... ...

详解:

/* 类声明(declaration)部分 */
class Abc
{
    int d0; 		//不指定访问权限时,默认为private。
    public: 		//公有权限
        int d1;
        double fun1();
    protected: 		//保护权限
        int d2;
        int fun2();
    private:		//私有权限
        double d3;
        void fun3();  
};
/* 函数成员的完整定义,类实现(implementation)部分 */
void Abc::fun3(){ ... ... }
double Abc::fun1(){ ... ... }
int Abc::fun2(){ ... ... }

2.2 数据成员的语法细则

数据成员也称为属性,是类中的变量,用于保存数据;数据成员的类型,可以是基本数类型,也可以是自定义数据类型;不同数据成员之间的类型可以相同,也可以不同。

数据成员不能与其他成员重名,声明数据成员的而语法形式类似于定义变量,所不同的是声明数据成员不能初始化。

2.3 函数成员的语法细则

函数成员也称为方法,是类中的函数,其功能通常是对本类中的数据成员进行处理;函数成员可以直接访问本类中的数据成员,数据成员相当于是类中的全局变量,函数成员可以直接调用本类中的其他函数成员。

在类中声明函数成员时,可以指定形参的默认值,即带默认形参值的函数。

不同函数成员之间可以重名,即重载函数,两个函数的形参个数不同,或数据类型不同,那么这两个函数就可以重载,函数成员不能与数据成员重名。

可以将函数成员定义成内联函数,在类实现部分定义函数成员时,可以用“inline”关键字将其定义成内联函数,或者直接将该函数成员定义在类声明部分的打括号里,c++编译器默认将直接定义在类声明部分大括号里的函数成员当做内联函数处理

2.4 访问权限的语法细则

每个类成员都有并且只有一种访问权限。定义类时,不同成员可以按任意位置编排,为便于阅读,不同权限成员编排在一起,或将数据成员编排在一起,函数成员编排在一起。

关键字public、protected和private指定了后续成员的访问权限,在类声明中,同一关键字可出现多次,也可以不出现(如果没有访问权限的成员)。通常,一个类需包含公有权限成员,否则该类没有对外接口,无法使用。

三、对象的定义和访问

类相当于时程序员自己定义的一种新的数据类型,可称为类类型,使用类通常是用类定义变量,用类定义的变量通常称为该类的对象或实例,使用类,需要先定义,在使用,或先声明再使用

3.1 类的定义和访问

定义对象和定义变量的语法形式基本相同。类就是一张图纸,计算机执行定义对象语句就是按照图纸再内存中创建一个程序实例(即对象),计算机会严格按照类定义来创建对象,所创建的对象具有类所规定的:数据成员、函数成员及访问权限。

定义好的对象可以访问,访问对象就是通过接口(即公有成员)操作内存中的对象,实现特定的程序功能,比如读写对象中的公有数据成员,或调用其中的公有函数成员。

对象中的公有成员可以访问,非公有成员(私有成员、保护成员)不能访问,这就是对象成员的访问权限控制

访问对象中的公有成员需要使用成员运算符点“.”,以“对象名.数据成员名”的形式访问对象中的变量,以“对象名.函数成员名(实参列表)”的形式调用对象中的函数。

3.2 对象指针即指向运算符

//假设定义了一个类Rectangle,
// 拥有公有数据成员a、b和公有函数成员RArea()、RLen()
Rectangle obj2; //定义一个Rectangle的对象obj2
obj2.a;  
obj2.b;  		//访问对象中的成员 
obj2.RArea();  
obj2.RLen() 

//定义一个对象指针p
Rectangle *p;
p = &obj2;
(*p).a;         //通过对象指针间接访问对象obj2中的公有成员
(*p).b;   		
(*p).RArea();  
(*p).RLen() 
p->a;        //通过指向运算符间接访问对象obj2中的公有成员
p->b;  		
p->RArea();  
p->RLen() 

即使通过指针,也只能访问类的公有成员,不能对私有成员和保护成员进行访问。

3.3 类与对象的编译原理

源程序中的c++代码,需要编译成等效的机器语言指令,才能被计算机硬件识别和执行,执行类代码的编译,编译时编译器将源程序中的类定义代码编译成等效的机器语言指令,当编译到类中的函数成员时,编译器会对其定义代码做出某些调整,然后再进行编译。编译器会为函数的行参指定一个指针常变量this(也就是:类名 *const this),这个指针是指向本类的指针,并修改所在函数体中,对本类的访问方式,自动在成员名前加上“ this->”(也就是:this->成员名)的方式间接访问。c++在编译器在编译通过对象访问函数其成员时,会将其修改为直接调用该函数成员,然后将对象的地址(&对象名)**作为实参,传递给对应的形参(*const this)

其目的是让多个同类对象,公用函数代码,降低内存占用,概念上讲,计算机严格按照类定义创建对象,同类的多个对象,都应该有类定义中规定的数据成员和函数成员。同类的多个对象,都包含各自的数据成员,每个对象所占用的内存空间,都等于类中全部数据成员所需内存空间的总和,但是他们的执行方法也就是函数是一样的,是可以共用的,因此编译器在编译时,通过调整函数成员的定义代码和调用形式,巧妙的实现了执行时,多个同类对象共用同一个函数,内存中只需保存一份函数代码即可。在这个过程中对象指针this扮演了重要角色。在编写函数成员代码时,形参this是隐含的,函数体中访问其他类成员也不需要添加this指针,这些都由编译器在编译时自动添加。

四、对象的构造与析构

4.1 变量在内存中的生存期

程序执行过程中,变量从内存分配,到释放,这个时间段被称为变量在内存中的生成期,不同类型的变量分配方式不同,在内存中的生成期也不同,全局变量和静态变量是静态分配的,他们在程序被加载后立即分配内存。直到程序执行结束退出时,才被释放,局部变量是自动分配的,在执行到其定义语句时为其分配内存待其所在代码块执行结束,即被释放。动态分配需要程序员使用new和delete运算符,自行决定何时分配及释放。

和变量一样,对象也有全局对象局部对象静态对象,也可以动态分配,程序执行时,对象也会经历从分配内存,到释放内存的过程,即对象具有生存期,不同类型的对象内存分配方式不同,在内存中具有不同的生存期,对象的内存分配方式和生成期和变量完全一样。

程序执行过程中:

  1. 计算机创建对象,为对象分配内存空间,我们称对象在内存中诞生了
  2. 当对象生存期结束时,计算机销毁对象,释放其内存空间,我们称为对象死亡了
  3. 创建对象的过程称为对象的构造,销毁对象的过程称为对象的析构。

4.2 参与对象的构造和析构的过程

构造个性化对象:

销毁个性化对象:

1、构造函数:

c++语言允许程序员在类定义中添加构造函数,参与对象的构造过程,执行定义对象语句时,计算机将自动调用对象所属类的构造函数,实现对象的个性化,构造函数是类中的一种特殊函数成员,定义构造函数时,应当遵守以下几点特殊的语法细则:

  1. 构造函数名必须与类名相同,定义时要在构造函数名前面加上类名的约束,指定是属于哪个类的构造函数,语法为:

    类名::类名(形参列表)
    { 赋值语句块 }
    
  2. 构造函数由计算机自动调用,程序员不能调用
  3. 构造函数通过形参传递初始值(可指定默认形参值),实现对新建对象数据成员的初始化。
  4. 构造函数可以重载,即定义多个同名的构造函数,这样可以提供多种形式的对象构造方法。
  5. 构造函数可以定义成内联函数
  6. 构造函数没有返回值,定义时不能写函数类型,写void也不行。
  7. 构造函数通常是类外调用,其访问权限应设为public或protected,不能设为private
  8. 一个类如果没有定义构造函数,编译器在编译时将自动添加一个空的构造函数,称为默认构造函数其形式为:类名( ) { },函数体中没有任何语句,就是什么也没做,但是从语法形式上,一个类必须由构造函数,

构造函数的常见功能主要有:初始化对象、显示对象的构造过程、构造时申请额外内存等

构造函数——初始化对象

在构造函数中给出数据成员的初始值,或初始化的方法,程序执行时,会自动调用构造函数,完成数据成员的初始化功能。

代码实例:

#include <iostream>
using namespace std;
class Student
{
    private:
        char *c_name;//定义为字符型指针变量,用于接收字符串传递的地址
        int c_ID;
        int c_age;
        double c_score;
    public:
        void print();
        Student(char *name, int ID, int age, double score);
};

Student::Student(char *name, int ID, int age, double score)
//char name[]形参用于接收实参传递过来的数组,实际是一个指针,必须定义接收字符串宽度
{
    c_name= name; //此处赋值,相当于是将实参的内存首地址赋值给了私有成员中的char* name指针变量    
	c_ID = ID;
    c_age = age;
    c_score = score;
}

void Student::print()
{
    cout<<"姓名:"<<c_name<<endl;
	//因c++对字符串处理的特殊机制,读取指针变量的地址,实际输出为这个字符串。
	// 要读取实际的地址的值时,需要强制转换指针类型
    cout<<"ID:"<<c_ID<<endl;
    cout<<"年龄:"<<c_age<<endl;
    cout<<"成绩:"<<c_score<<endl;
  }
int main()
{
    char name[]= "zhangsan";  //对字符串数组name进行赋值
    /*
    此时实参变量名name相当于是字符串name的首地址。
    如果直接写入字符串,而不是变量名,c++会将实参字符串看作是字符串常量    
	因为构造函数中,c++将char name[] 自动编译为一个指针,也就是char* name[],
	无法将一个字符串常量负值给一个字符型指针。故而报错
    */
    Student stu(name, 8, 12, 89); //不能在实参name的位置写字符串,会报错。只能间接传递
    stu.print();
    return 0;
}

当构造函数中定义有重载函数时,及两个名称一样,但是形参不一样,或形参数量不一样的时候,c++将根据形参匹配的原则,也就是形实结合,自动调用形参和实参相匹配的构造函数。代码实例:

class Circle
{
    private:
        double r;
    public:
        double area();
        //将构造函数定义为2个重载函数,其中一个带形参。一个不带
        Circle(double r);
        Circle();
};//定义方法成员double Circle::area() return(3.14*r*r)//定义重载构造函数,对变量r进行初始化
Circle::Circle(double r) {Circle::r = r}Circle::Circle() {Circle::r = 0 }

int main()
{
    Circle carea(5.3); //此时调用构造函数Circle(double r);
    Circle carea();//此时调用构造函数Circle();
    return 0;
}

类似于上述例子中的两个重载函数,可以写成带默认新参值的构造函数,将两个构造函数合二为一,简化代码。如下所示:

Circle::Circle(double r = 0){ r = x };

构造函数——用一个已经存在的对象,初始化新对象

这种方式又称为拷贝构造函数,例如上述例子中的圆形类Circle,重新定义一个新的构造函数,并引用已经定义好的对象实例carea。

Circle::Circle(Circle &carea)//&carea是一个本类对象的引用
{ r = carea.r }

拷贝构造函数接收一个已经存在的本类对象的引用,将该对象的数据成员一一对应,拷贝给新的对象的数据成员。实现一个用已经存在的对象,初始化新对象的功能。完整实例代码如下:

#include <iostream>
using namespace std;
class Circle
{
    private:
        double R; //私有成员R,无法直接外部访问赋值
    public:
        double area();
        //声明构造函数,两个构造函数为重载函数,第二为拷贝构造函数。
        Circle(double r);
        Circle(Circle &);
};
//定义方法成员
double Circle::area(){return(3.14*R*R);} 
//定义重载构造函数,对变量r进行初始化
Circle::Circle(double r=0 ) { R = r;}
//定义拷贝构造函数
Circle::Circle(Circle &carea) { R = carea.R;
}

int main(){
    Circle carea1(5.3);   	// 此时调用构造函数Circle(double r);
    Circle carea2(carea1);	// 此时调用拷贝构造函数Circle(Circle &);
			        // 利用已经存在的对象carea初始化carea2
    cout<<carea2.area();
    return 0;
}

可以看出,每添加一个构造函数,就为定义该类对象,增加了一种初始化形式,如果没有为类定义拷贝构造函数,则编译器在编译时,将自动添加一个默认拷贝构造函数,其功能,就是将实参对象的数据成员,一一对应,拷贝给新建的对象的数据成员。

构造函数——显示对象的构造过程

可以在构造函数的函数体中插入一些cout指令,这样可以让程序员在程序调试过程中实时观测到对象的构造过程,便于检查程序代码中的错误。实例如下:

//定义重载构造函数
Circle::Circle()
{R=0; cout<<"Circle()called."<<endl;}

Circle::Circle(double x)
{R=x; cout<<"Circle(double x)called."<<endl;}

Circle::Circle(Circle &carea)
{r=carea.R; cout<<"Circle(Circle &carea)called."<<endl;}

Circle obj;// 显示信息:Circle()called.

构造函数——构造函数时申请额外内存

以学生信息录入为例,定义一个学生数据存储的类Student,该类的数据成员和方法如下所示:

#include <iostream>
#include <cstring> //使用字符串拷贝函数需包含的文件头,C语言是<string.h>,c++是<cstring>,两者完全一样。
using namespace std;
class Student
{
private:
    char name[10],ID[11];//约束数据成员的字符串长度
    int age; double score;
public:
    void show();
    Student(char *pname, char *pID, int in_age, double in_score)//内联构造函数
    {
        strcpy(name,pname); strcpy(ID,pID);//通过字符串拷贝函数,将形参传递的值,赋值给Student类的数据成员
        age = in_age; score = in_score;
    };
};

void Student::show(){
    cout<<"学生姓名:"<<name<<endl;
    cout<<"学生ID:"<<ID<<endl;
    cout<<"学生年龄:"<<age<<endl;
    cout<<"学生成绩:"<<score<<endl;
}

int main()
{
    char name[]="张三";
    char ID[] = "1400500001";
    Student obj(name,ID,19,95);
    obj.show();
    return 0;
}

如果要在Student类中增加一个备注信息memo,备注信息可有可无,有长有短,该如何定义呢?可以将备注信息定义为字符型指针char *memo,然后在构造函数中按照实际的备注信息长度来动态分配内存,即申请额外的内存。方法如下:

#include <iostream>
#include <cstring>
using namespace std;
class Student
{
private:
    char name[10],ID[11];
    int age; double score;
    char *memo;
public:
    void show();
    Student(char *pname, char *pID, int in_age, double in_score,char*pmemo)
    {
        strcpy(name,pname); strcpy(ID,pID);
        age = in_age; score = in_score;
        int len = strlen(pmemo);//计算实际传递过来的备注信息的长度
        if(len<=0) memo=0;//如果备注信息为空,则将指针赋值为0,也就是空指针。
        else
        {
            memo = new char[len+1];//按照实际的长度分配内存,加1的原因是字符串末尾结算符有一个\0占位符            strcpy(memo, pmemo);//使用传递过来的备注信息,初始化数据成员。
        }
    };

    ~Student() //析构函数,用于销毁构造函数动态分配的内存
    {
        if(memo!=0) delete []memo;
    };
};
void Student::show()
{
    cout<<"学生姓名:"<<name<<endl;
    cout<<"学生ID:"<<ID<<endl;
    cout<<"学生年龄:"<<age<<endl;
    cout<<"学生成绩:"<<score<<endl;
    cout<<"学生情况:"<<memo<<endl;
}

int main()
{
    char name[]="张三";
    char ID[] = "1400500001";
    char memo[] = "成绩优秀";
    Student obj(name,ID,19,95,memo);
    obj.show();
    return 0;
}

合理使用内存的动态分配技术,可以有效降低内存的使用量,动态分配的内存,需要程序员编写delete语句来释放,在构造函数中动态分配的内存,需要程序员编写析构函数,在析构函数中,用delete语句释放。

2、析构函数

概念:当对象生存期结束时,计算机销毁对象,释放其内存空间,这个过程就是对象的析构。

c++语言允许程序员在类定义中添加析构,参与对象的析构过程,计算机在销毁对象时,自动调用该类的析构函数。与构造函数一样,析构函数也是类中的一种特殊函数成员,定义析构函数时,应当遵守以下几点语法细则

析构函数的功能主要有:清理内存;设置与当前对象相关的系统状态等。实例可以参考前文的学生信息类Student。

3、拷贝函数中的深拷贝与浅拷贝

当类中未定义拷贝构造函数时,c++编译器将添加一个默认的拷贝构造函数,默认拷贝构造函数的功能是把实参对象的数据成员,一一对应,拷贝给新建的数据成员,这时候,两个实例化对象使用的是同一个内存单元,称之为浅拷贝,如果两个对象各自使用自己的内存单元,只是将一个对象的信息复制给另一个对象,这种方式称为深拷贝,就需要对新的对象动态分配新的内存单元,再进行赋值初始化。

实列代码:学生信息类Student

#include <iostream>
#include <cstring>
using namespace std;
class Student
{
private:
    char name[10],ID[11];
    int age; double score;
    char *memo;
public:
    void show();
    Student(char *pname, char *pID, int in_age, double in_score,char*pmemo)
    {
        strcpy(name,pname); strcpy(ID,pID);
        age = in_age; score = in_score;
        int len = strlen(pmemo);
        if(len<=0) memo=0;
        else
        {
            memo = new char[len+1];
            strcpy(memo, pmemo);
        }
    };

    Student(Student&obj)//定义拷贝结构函数,使用深度拷贝的方法。
    {
        strcpy(name,obj.name); strcpy(ID,obj.ID);
        age = obj.age; score = obj.score;
        int len = strlen(obj.memo);
        if(len<=0) memo=0;
        else
        {
            memo = new char[len+1];
            strcpy(memo, obj.memo);
        }
    };

      ~Student() //析构函数,用于销毁动态分配的内存
    {
        if(memo!=0) delete []memo;
    };
};

void Student::show()
{
    cout<<"学生姓名:"<<name<<endl;
    cout<<"学生ID:"<<ID<<endl;
    cout<<"学生年龄:"<<age<<endl;
    cout<<"学生成绩:"<<score<<endl;
    cout<<"学生情况:"<<memo<<endl;
}

int main()
{
    char name[]="张三";
    char ID[] = "1400500001";
    char memo[] = "成绩优秀";
    Student obj(name,ID,19,95,memo);
    Student obj_n(obj);
    obj_n.show();
    return 0;
}

4.3 类和对象编程的主要内容

面向对象程序设计,主要包含2方面内容,即先定义类,然后用类定义对象,并访问对象及其成员

定义类:程序员再定义类时要考虑5大要素,即数据成员、函数成员、各个成员的权限、构造函数和析构函数

定义对象:将类当作一种自定义数据类型来定义变量,所定义的变量就称为对象,计算机执行定义对象语句就是严格按照类所描述的5大要素在内存中创建该类的对象,所创建的对象具有类所规定的数据成员、函数成员及访问权限。

访问对象:访问对象就是通过接口(即公有成员)操作内存中的对象,实现特定的程序功能,比如读写对象中的公有数据成员,或调用其中的公有函数成员。

c++语言使用类的语法形式来描述类模型,将属性定义成类的数据成员,将方法定义成类的函数函数成员,通过为类成员设定访问权限来实现类的封装,为类添加构造函数可以为该类对象提供初始化方法,添加析构函数可以在销毁对象时做某些特定的善后处理。

任务:编写一个模拟银行存款账户管理的c++程序

要求:能够准确记录存款账户的:户名、账号、存款金额,管理功能应当具备:开户、存款、取款、查询等操作

编写头文件:声明类代码

class Account
{
    public:
        int a_num;      //账号
        char a_name[10];//户名
        float money;    //金额
        void deposit(); //存款
        void withdraw();//取款
        void show();    //余额查询
};

定义方法:

#include <iostream>
#include "account.h"
using namespace std;
//存款方法
void Account::deposit()
{
    cout<<"请输入存款金额:";
    float x; cin>>x;
    money += x;
    show();
}

//定义取款方法
void Account::withdraw()
{
    cout<<"请输入取款金额:";
    float x; cin>>x;
    if(money<x) cout<<"余额不足";
    else money -= x;
    show();
}

//余额查询
void Account::show()
{
    cout<<"账号"<<a_num<<"的余额为:"<<money<<"元\n\n";
}

定义主函数:

#include <iostream>
#include "account.h"
#include <string.h>
using namespace std;
int main()
{
    cout<<"请输入开户信息(账号、户名、存款金额):";
    //定义3个临时变量,用于保存账号、户名、存款金额
    int x; char str[10]; float y;
     cin>>x>>str>>y;
    //创建对象并对数据成员进行赋值操作
    Account obj;
    obj.a_num = x; strcpy(obj.a_name,str); obj.money = y;
    int choice;
    while (true)
    {
        cout<<"1-存款\n2-取款\n3-查询余额\n0-退出\n请选择:";
        cin>>choice;
        if(choice==0) break;
        if(choice==1) obj.deposit();
        else if(choice==2) obj.withdraw();
        else if(choice==3) obj.show();
    }
    return 0;
}

上述例子中,虽然实现了开发需求所要求的功能,但是任然存在很多漏洞,例如客户信息存放在公有成员中,会非常不安全,主函数在创建对象时,无法初始化。这就需要通过结构函数和析构函数来解决上述问题。

五、对象的应用

5.1 对象数组

可以定义一个对象数组来保存多个对象。

实例代码:正方形类Square,用于求面积

#include <iostream>
using namespace std;
class Square
{
    public:
        double length;//保存边长
    public:
        double area() //计算面积
        {
             return (length*length);
          };

Square(double x = 0)//带默认形参值得结构函数
  {
      length = x;
    };
};

int main()
{
    /*
    定义正方形类Square的对象obj
    数组obj有3个元素,每个元素是一个正方形对象,边长为2,3,4
    */
    Square obj[3] = {Square(2),Square(3),Square(4)};
        //显示面积
    for(int x=0; x<3;x++)
    {
        cout<<obj[x].area()<<endl;
    }
    return 0;
}

1、定义对象数组的语法:

与定义普通数组的方法基本相同

//类名 对象命名标识符[表达式];
Square obj[3];//为Square定义一个3个对象的对象数组
Square obj[3]={Square(2),Square(3),Square(4)};//为Square定义一个3个对象的对象数组,并初始化
Square obj[3]={Square(2),Square(3)};//部分初始化

构造:有多个数组元素,就会调用多少次构造函数。

2、对象数组的访问:

可以访问对象中的公有成员。

下标法:

//对象数组名[].公有成员名
cout<<obj[x].area();//访问area()方法

指针法:

Square*p = obj[n].a<<endl;
cout<<p->area()<<endl;

析构:有多少个数组元素,就会调用多少次析构函数

5.2 对象的动态分配

内存的动态分配,必须使用指针才能完成。

Square *p; //定义对象指针
p = new Square;//使用new运算符动态分配一个对象
... ...
delete p;

使用new运算符动态分配单个对象的内存单元时,需要指定类型,分配成功后将返回内存单元的首地址,需要预先定义一个同类的对象指针来保存这个首地址,后续访问时,将使用这个指针间接访问,释放内存单元时,也需要使用对象指针,来指定释放那个哪内存单元。动态分配单个对象时,可以进行初始化

p = new Square(2);

内存使用完以后也需要及时释放:

动态分配某个类的对象时,会自动调用这个类的构造函数,删除该类时,也会自动调用该类的析构函数,

动态对象数组的分配与释放:

使用new运算符,动态分配对象数组时,可以在程序执行时,更具需要,分配适量的内存,减少内存的不必要占用。内存使用完以后,也因及时释放。例子如下:

Square *p;
p = new Square[3];
p[0].a = 2;  cout<<p[0].area()<<endl;
(p+1)-> a=3;  cout<<(p+1)->area()<<endl;
... ...
delete[]p;

动态分配数组中,有多少个元素,就会调用多少次构造函数,删除数组时,有多少个元素,就会调用多少次析构函数。

5.3 对象在函数中的应用

1、对象作为函数的形参

定义在函数内部的对象是局部对象,其他函数不能直接访问,对象可以作为参数在函数间传递,调用函数时,主调函数与被调函数之间,需要通过形实结合来传递对象,将保存在主函数里的对象以对象实参的形式,传递给被调函数的对象形参。

#include <iostream>
using namespace std;
class Square //定义一个正方形类
{
    public:
        double a; //保存边长
        double area() //求正方形面积
        { return (a*a); }
        Square(double x=0) //带默认形参值的构造函数,内联函数
        {a=x;}
};

double inner_circle_area(Square s ) //求正方形内切圆面积,形参是一个正方形对象形参。
{
    double r = s.a/2; //通过形式结合,读取正方形的边长
    return(3.14*r*r);
}

int main(){
    Square obj(10);//定义一个正方形对象:obj,边长为10. 
   cout<<inner_circle_area(obj)<<endl;//调用函数,求出内切圆面积
    return 0;
}

上述例子中,对象形参:Square s,在调用时,相当于执行了Square(obj)指令,使用对象obj进行初始化,也就是调用了Square类的的默认拷贝函数。

5.4 函数间的参数传递

函数间的参数传递,分为值传递、引用传递、指针传递,3种类型,下面分别介绍在函数间传递对象时的值传递、引用传递、和指针传递。

1、值传递与常对象

函数间传递对象的值传递与函数间的值传递方法一致。实例可参考5.3节的实例代码。

像常变量一样,可以将任何只读不改的对象,定义为常对象。常对象定义时必须初始化,定义后不能再修改起数据成员。例如5.3求内切圆实例种的对象Square:

const Square obj(2);
cout<<obj.a<<endl;
obj.a=5;//错误
/*使用常对象形参*/
double inner_circle_area(const Square s ) 
{ ... ... }

值传递,实际上是重修构造了一个对象形参,这需要花费执行时间和内存空间,相比而言,引用传递和指针传递拥有更高的传递效率。

2、引用传递与常引用

引用传递将被调函数的对象形参定义成主调函数中对象实参的引用,被调函数通过该引用间接访问该对象,实例如下:

double inner_circle_area(Square &s ) //定义时在形参s前加引用说明符
{
 double r = s.a/2;  return(3.14*r*r);
}
//主调函数中实参保持不变
cout<<inner_circle_area(obj)<<endl;

引用传递一方面可以提高数据传递效率,另一方面也有副作用,例如上述例子中,被调函数中任何对引用形参的数值修改,都会影响到所引用主调函数中的所对应的对象。如果要避免这种因函数间因引用带来的相互影响,可以将对象形参定义为常引用,就是在定义时加上const关键字。

double inner_circle_area(const Square &s){... ...}

将被调函数的对象形参定义为常引用后,任何通过该引用修改主调函数的对象实参的做法都是错误的,编译器会提示该错误信息。

3、指针传递与指向常对象的指针

指针传递将主调函数中对象实参的内存地址传递给被调函数的对象指针。被调函数通过该对象指针,间接访问主调函数中的对象。

//被调函数
double inner_circle_area(Square *s)
{
 double r = s->a/2;  return(3.14*r*r);
}
//主调函数,调用的实参,需要对对象进行取地址操作。
cout<<inner_circle_area(&obj)<<endl;

和引用传递一样,指针传递可以提高传递效率,另一方面也有副作用,如果要避免因指针传递导致的函数间的相互影响,可以将被调函数的对象指针定义为指向常变量的对象指针,定义方式同样是在定义语句前加上const关键字

//指向常变量的对象指针
double inner_circle_area(const Square *s){... ...}

和引用传递一样,通过指向常变量的指针去间接修改对象实参的做法都是错误的。编译器会提示错误信息。

综上所述,函数间传递对象,有3种方式,分别是值传递、引用传递和指针传递,如果功能设计上,被调函数不需要修改主调函数传递过来的对象,而只是读取其中的数据,那么程序员可以主动的将被调函数的形参定义为常对象、常引用或指向常对象的指针。如果被调回函数的函数体中存在有修改对象的语句,编译器将会提示错误信息从而帮助程序员迅速排查这方面的错误。

六、类中的常成员与静态成员

const数据保护机制

常变量、常对象、常引用或指向常对象的指针等,在定义时都使用了const关键字,这是c++语言引用的一种数据保护机制称为const数据保护机制。

例如如果功能上只是使用主调函数传递过来的数据,而不需要修改,那么程序员在编写程序时,可以使用const关键字,主动对被调函数的形参进行限定。限定被调函数不能修改主调函数传递过来的数据,否则编译时,编译器会提示错误信息,这样就可以帮助程序员迅速排查错误。在定义类时,也可以使用const保护机制,保护数据成员不被修改。

stacit静态机制:

c++语言中的static静态机制是与作用域、生存期相关的一个概念。例如静态可以延长局部变量的生存期,可以将全局变量和函数的作用域,限定在本程序文件之内,我们统称为static静态机制。

类中的常成员与静态成员:

在类里,也可以定义为常成员和静态成员。

本章将以一个计算圆形池子造价的实例进行讲解,代码如下:

#include <iostream>
using namespace std;
class CirclePool
{
    private:
        double price;//单价
        double r;
    public:
        //构造函数,初始化单价price和半径r
        CirclePool(double p1 =0,double p2 = 0)
        {
            price = p1;
            r = p2;
        }
        //对初始化的单价的合法性进行检查
        void CheckPrice(double x)
        {
            if (x<=0)
            {
               price=0;
               cout<<"数据输出错误"<<endl;
            }
            else price =x;
        }
        //获取检查后的单价
        double GetPrice()
        { return price; }
        //对半径进行检查
        void CheckRadius(double x)
        {
            if (x<=0)
            {
                r = 0;
                cout<<"数据输出错误"<<endl;
            }
            else r =x;
        }
        //读取检查后的半径
        double GetRadius()
        { return r; }
        //计算圆形水池的造价
        double GetCost()
        { return(3.14*r*r*price);
         }
 };

 int main()
 {
    double totalcost = 0;//保存造价计算结果
    double r1,r2;
    cin>>r1>>r2;
    CirclePool pool1,pool2;//定义2个对象
    pool1.CheckPrice(10);
    pool1.CheckRadius(r1);
    totalcost += pool1.GetCost();
    pool2.CheckPrice(10);
    pool2.CheckRadius(r1);
    totalcost += pool2.GetCost();
    cout<<totalcost<<endl;
    return 0;
 }

定义时,使用const关键字限定的称为常成员,数据成员和函数成员均可以定义成常成员,定义常成员的目的是保护数据成员不被修改。

6.1 常数据成员

如果一个数据成员保存的是一个常量值,初始化后不会改变,那么可以将该数据成员其定义成常数据成员,换句话说,如果定义成常数据成员,那么该成员只能在初始化时赋值,初始化后不能再次赋值,长数据成员的含义与常变量大致相同,所不同的是,在类中声明常数据成员时,不能直接初始化,类中的任何成员都不能声明时直接赋初始值,只能通过构造函数来进行初始化。

上述代码中,price保存的是一个常量,初始化后,就不会改变,故代码可以定义为如下的方式:

#include <iostream>
using namespace std;

class CirclePool
{
    private:
        const double price;//单价
        double r;
    public:
        /*构造函数,初始化单价price和半径r,
        在构造函数中,不能直接对常数据对象初始化,必须使用初始化列表的形式 */
        CirclePool(double p1 =0,double p2 = 0):price(p1)
        {
            r = p2;
        }
        double GetPrice()
        { return price; }
        //对半径进行检查
        void CheckRadius(double x)
        {
            if (x<=0)
            {
                r = 0;
                cout<<"数据输出错误"<<endl;
            }
            else r =x;
        }
        //读取检查后的半径
        double GetRadius()
        { return r; }
        //计算圆形水池的造价
        double GetCost()
        { return(3.14*r*r*price); }
};

int main()
{
    double totalcost = 0;
    double r1,r2;
    cin>>r1>>r2;
    CirclePool pool1(10,r1),pool2(10,r2);

    pool1.CheckRadius(r1);
    totalcost += pool1.GetCost();
    pool2.CheckRadius(r1);
    totalcost += pool2.GetCost();

    cout<<totalcost<<endl;
    return 0;
 }

常数据成员的语法细则:

  构造函数名(形参列表):常数据成员名1(形参1),常数据成员名1(形参1),... ...
  {
      ...;//其他数据成员在此初始化
  }
  // 形参1、形参2是从形参列表中提取出来的,并在初始化列表中进行二次接力传递。

6.2 常函数成员

如果某个函数成员只需要读取类中其他数据成员的值,不需要修改,那么可以将该函数定义成常函数成员。换句话说,如果将某个函数成员定义成常函数成员,那么该成员只能读取这个类中的数据成员,不能赋值修改。

在例子中一个函数成员GetCost他是读取数据成员的半径,计算圆形面积和造价,这个成员满足常函数成员的条件,可以将其定义成常函数成员。定义常函数成员有2种语法形式。

内联函数:

就是在类声明大括号中定义的函数

double GetCost() const { return(3.14*r*r);}

定义时在函数名小括号后面添加const关键字

非内联函数:

需要在类声明部分,在函数名小括号后添加关键字const,在类实现部分,同样需要在需要在函数名小括号后面添加关键字const。

//类声明部分
double GetCost() const;

//类实现部分
double CirclePool::GetCost() const{ return(3.14*r*r); }

常函数成员的语法细则:

6.3 静态数据成员

定义类时,用static关键字进行限定的成员称为静态成员,数据成员和函数成员都可以定义成静态成员。c++语言中的static静态机制是与作用域、生存期相关的一个概念。例如静态可以延长局部变量的生存期,可以将全局变量和函数的作用域,限定在本程序文件之内。

全局变量,同样可以延申作用域和生存期,但是面向对象程序设计,建议将全局变量作为一种特殊成员,归属到某个类中进行同一管理,这种特殊成员就是静态数据成员。

使用静态成员修改CirclePool类:

#include <iostream>
using namespace std;
class CirclePool
{
    private:
        static double price;//静态数据成员,使用static声明
        double r;
    public:
        /*构造函数,半径r,
        在构造函数中,不能直接对静态数据成员初始化,必须在类实现部分进行实现 */
        CirclePool(double p = 0)//构造函数初始化半径r
        {
            r = p;
        }
        double GetPrice()
        { return price; }
        //对半径进行检查
        void CheckRadius(double x)
        {
            if (x<=0)
            {
                r = 0;
                cout<<"数据输出错误"<<endl;
            }
            else r =x;
        }
        //读取检查后的半径
        double GetRadius()
        { return r; }
        //计算圆形水池的造价
        double GetCost()
        { return(3.14*r*r*price);
 }
};

double CirclePool::price = 10;//在类实现部分进行静态成员的初始化,定义时前面类名进行指定是哪个类的静态数据成员

int main()
{
    double totalcost = 0;
    double r1,r2;
    cin>>r1>>r2;
    CirclePool pool1(r1),pool2(r2);
    pool1.CheckRadius(r1);
    totalcost += pool1.GetCost();
    pool2.CheckRadius(r1);
    totalcost += pool2.GetCost();
    cout<<totalcost<<endl;
    return 0;
}

静态数据成员的语法细则:

6.4 静态函数成员

如果某个函数成员只需要访问类中的静态数据成员,那么可以将该函数定义成静态函数成员,换句话说,如果将某个函数成员定义成静态函数成员,那么该函数只能访问类中的静态数据成员,或调用类中其他静态函数成员。

例子:修改圆形水池类CirclePool中的GetPrice方法。

#include <iostream>
using namespace std;
class CirclePool
{
    private:
        static double price;//静态数据成员,使用static声明
        double r;
    public:
        /*构造函数,半径r,
        在构造函数中,不能直接对静态数据成员初始化,必须在类实现部分进行实现 */
        CirclePool(double p = 0)//构造函数初始化半径r
        {
            r = p;
        }
        static double GetPrice();//类声明部分只能声明,不能写函数体。函数体只能写在类实现部分
... ...;//其他代码省略
};

double CirclePool::price = 10;//在类实现部分进行静态成员的初始化,定义时前面类名进行指定是哪个类的静态数据成员//类实现部分,不需再加static,对该静态函数的方法进行实现。
double CirclePool::GetPrice(){ return price;}//price必须是静态数据成员。
   ... ...;//主函数代码省略

静态函数成员的语法细则:

6.5 总结

面向对象程序设计,希望用类管理所有的程序代码,程序中没有游离在类外的全局变量或外部函数,对于一些通用的全局变量和函数,可以将其定义成静态成员,从形式上归为一类进行同一管理。例如我们可以定义一个数学类Math,来管理这些数据和方法。

//类声明与类实现部分
class Math
{
    public:
        static double pie;
        static double sin(double x);
        static double cos(double x);
    ... ...
};

double Math::pie = 3.1415926;
double Math::sin(double x){ ... ... };
double Math::cos(double x){ ... ... };
... ...
//访问
cout<<Math::pie<<endl;cout<<Math::cos(2)<<endl;

七、类的友元

友元是一个与访问权限相关的概念,编写类的程序员,定义类的时候,为类成员赋予不同的访问权限,将必须被外部访问的成员开放出来,以保证类的功能能够正常使用,将不需要被外部访问的成员隐藏起来以防止他们被误访问,被隐藏的成员,只能在内部使用,即能被类里的其他成员访问, 但不能在类的外部访问;使用类的程序员在编写程序代码时,用类定义对象,定义好的对象可以访问,访问对象就是通过其接口,即公有成员来操作内存中的对象,实现特定的程序功能,比如读写其中的公有成员或调用其中的公有函数成员。

假设有一个类A,如下所示:

class A
{
    public:
        int x;//公有成员
    protected:
        int y;//保护成员
    private:
        int z;//私有成员
        //构造函数
        A(int p1=0,int p2=0,int p3=0)
        { x = p1; y = p2; z = p3;
 }
};

void fun()
{
    A obj(1,2,3);
    cout<<obj.x<<endl;//正确,公有成员可以访问
    cout<<obj.y<<endl;//错误,保护成员不能被外部函数访问
    cout<<obj.z<<endl;//错误,私有成员不能被外部函数访问
}

类中设定的访问权限对类外的函数一视同仁,具有相同的约束力,公有成员对大家都开放,都能访问,而私有成员和保护成员是隐藏的,对大家都不开放,不能访问。

内否向类某些函数定向开放某些成员,实现更加精细化的类成员访问控制呢?例如程序员在定义类的时候,可以授权某些函数可以访问类中的私有成员,c++语言可以在定义类的时候声明友元,向类外的某些函数或类定向的开放类中的所有成员,被类声明成友元的函数,称为该类的友元函数,被声明成友元的类,被称为友元类。

7.1 友元函数

友元声明语法细则:

class 类名
{
    ...
    friend 友元函数的原型声明;
};
class A
{
    public:
        int x;//公有成员
    protected:
        int y;//保护成员
    private:
        int z;//私有成员
        //构造函数
        A(int p1=0,int p2=0,int p3=0)
        { x = p1; y = p2; z = p3; }
    friend void fun(); //声明友元函数fun(),可以在类中任意位置
};

void fun()
{
    A obj(1,2,3);
    cout<<obj.x<<endl;//正确,公有成员可以访问
    cout<<obj.y<<endl;//正确,保护成员能被友元函数访问
    cout<<obj.z<<endl;//错误,私有成员能被友元函数访问
}

7.2 友元类

类A的友元函数,可以是另一个类B的函数成员,假设本节例子中类A的友元函数是类B的函数成员,类B的声明如下:

class B
{
    ... ...
    void fun1(){... ...}
    void fun2(){... ...}
};

则在类A中声明友元函数fun1时,需要在函数名前加上类名限定:

class A
{
    ... ...
    friend void B::fun1();//声明类B的函数成员fun1为类A的友元函数
};

如果类B的函数成员都是类A的友元函数,则称类B为类A的友元类。声明友元类时,不需要逐个声明其所有的函数成员,而是采用同一声明的语法形式:

class A
{
    ... ...
    friend class B;//声明类B为类A的友元类
};

友元类的注意事项:

  1. 友元关系是单向的:若类A声明类B是自己的友元,并不意味着自己同时称为对方的友元,除非对方声明自己是它的友元
  2. 友元关系不能传递:假设类B是类A的友元,类C是类B的友元,这并不意味着类A和类C之间存在任何友元关系,除非它们自己单独声明