面向对象编程是一个以对象为主要关注点的编程方法,其实现主要依靠类与对象。类本身可以视作程序与用户之间的一个接口(interface),类的编写者根据特定的数据描述方式,将数据表示和操纵这些数据的方法封装在类中。使得类实现以后,用户可以使用这些类进行编程。在类中,公有方法是公共接口,用户通过这些方法与类交互(如调用、修改、显示类中的数据成员等)。
目录
面向对象编程(OOP)
面向对象编程(Object Oriented Programming,OOP)是一种设计程序的概念性方法。这种概念性方法其实不与某种编程语言完全绑定(但不同语言的特性影响了实现OOP的难易程度),例如C++、C#、JAVA都是经典的OOP语言,但C语言也可以用于实现OOP。
- 面向对象编程的定义
面向对象编程将问题分解成各个对象,通过各个对象的组合来解决问题。建立对象的目的不是为了完成一个步骤,而是为了描述某个事物在整个解决问题的步骤中的行为。
OOP首先考虑对象所需的数据、操作,即描述接口。后再确定如何组织数据、实现接口,并将这两者封装在一起。最后再利用这个封装好的对象进行编程。
- OOP的三大特性
OOP具有许多特性,其中最重要的三点是继承性、多态性与封装性
#继承性
指某个类可以从一个类(称为父类)中复制其所有的组成(包括数据成员、方法),同时有区别于其父类的组成。
#多态性
从宏观的角度来讲,多态性指当不同的多个对象同时接收到同一个完全相同的消息之后,所表现出来的动作是各不相同的,具有多种形态。
从微观的角度来讲,多态性指在一个类中,面向对象技术可以使用相同的调用方式来对相同的函数名进行调用,即便这若干个具有相同函数名的函数所表示的函数是不同的。
#封装性
封装是指将一个系统中的数据以及与这个数据相关的一切操作组装到一起封装在一个“模块”内。(C++ primer plus中将实现细节放在一起并将它们与抽象分开称为“封装”,感觉这没表现出二者的统一性)。
- OOP与面向过程编程的区别
在提出面向对象编程之前,主要是面向过程编程(Procedure Oriented Programming,POP)。POP将问题分解为几个过程,后再针对这些过程提出数据表示方法、确定如何实现。
二者解决问题的角度是不同的。OOP关注的是问题涉及的相关对象,POP关注的组成问题的过程。
接口
从广义上讲,接口是两个系统间交互时使用的共享框架,比如电脑与打印机之间有接口,网络七层结构之间也有接口,电脑和手机的USB接口,类与类用户之间有接口(公有方法)。
类的声明、实现
类由数据成员与与其搭配使用的方法组成。数据成员描述了类对象的数据表示与组织方法,类方法则提供了类与用户交互的方式(即接口)。方法由成员函数(如显示、修改等)、构造函数、析构函数、类型转换函数组成。
C++中将类的声明与类的实现分离,具体做法是将声明放在头文件(.h)中,实现代码则放在源代码文件(.cpp)中,后在使用时,于另一个源代码文件中包含头文件,最后将三者共同编译(常见做法是将三者放在同一个解决方案中)。
将声明与实现分离,有利于维护,特别是接口抽象的比较好时,此时可以不改变声明接口、使用代码,只修改实现即可维护。接口抽象是类设计的关键。
- 类的声明 类的组成分为private、protected、public三种。private、protected、public实际上是访问控制的关键字:
#private表示私有部分,在类外只能通过public成员访问,但public部分的函数可以访问private里的数据成员与成员函数,常将数据成员放在private中;
#protected表示保护部分,其在类外只能用public成员访问protected成员,但派生类的成员(函数)可以使用基类的保护成员。
#public表示公有部分,类外可以访问,故将用户可以调用的函数放于public部分。
C++中的类通过关键字class声明,代码格式如下:
也有部分较短的代码可以于类声明时(忽略函数原型)直接给出定义,此时这样的函数将自动成为内联函数。/*类定义的一般格式: #ifndef 类名_H//一般将类声明放在头文件 #define 类名_H #include... class 类名 { private://不写private也可以,C++默认类对象是private的 ...; public: ...; };//分号不能忘,类似于结构定义 #endif */ //一个示例 //student.h #ifnedf student_H_ #define student_H_ class student { private: char * pri_id; char * pri_grade; public: student(char *id,char *grade);// ~student();//析构函数 void showID(); char * getGrade(student & stu); }; #endif
根据类生成的不同对象最终将有各自的数据成员,但是它们都没有内存用于储存函数定义的指令,而是共享相同的函数定义。 - 类的实现
类的实现指类中的数据成员(如静态变量)初始化、成员函数定义。类实现的代码如下:
在类的实现中,定义类的成员函数需要使用作用域解析运算符::来标识函数所属的类,实际上::运算符说明类方法具有的是类作用域,只能通过对象调用,而不能在类外直接调用。/* #include "类所在头文件名" #include ...//需要的头文件 静态变量类型 类名::静态变量名=值;//若有静态变量 ... 返回类型 类名::函数名1 (参数列表) { } ... */ //student.cpp #include "student.h" #include<iostream> student::student(int id,char *grade) { ... } student::~student() { ... } void student::showID() { ... } char * student::getGrade(student & stu) { ... }
- 类的使用
类的使用代码位于另外的源代码文件中,但是需要将这个文件与其声明、实现的文件共同编译。
/* //一些代码调用格式 类名 对象名=类名(参数);//创建类对象 对象名.成员函数名(参数);//调用成员函数格式 */ //user.cpp #include<iostream> #include<string> #include"student.h" int main { student Henry=student("000001","G301"); student Leo=student("1531212","G301"); Leo.showID(); string grade=Henry.getGrade(Henry); }
特殊的成员函数:构造函数、复制函数、赋值运算符、析构函数
类的构造函数、复制函数、赋值运算符、析构函数时类的十分重要的函数。构造函数用于生成对象,复制函数与赋值运算符函数用于执行用同类对象生成新的对象,析构函数则用于释放对象本身的内存。这些函数都有默认版本,也就是说如果没有显式提供默认的构造/复制/赋值/析构函数,编译器将会自动生成一个。
- 构造函数(constructor)
· 构造函数
构造函数用于初始化成员对象,分为显式构造函数与默认构造函数两种,默认构造函数用于创建对象时提供的参数不足的情况。
构造函数具有以下特点:
#构造函数没有返回值(或者说声明类型)
#构造函数的函数名与类名相同
#构造函数应位于类中的公有部分(public)
此外,还需要注意的一个小细节是构造函数使用的参量名不能与类数据成员名相同,否则使得编译器将参量与成员弄混。
构造函数的声明、定义代码如下:
构造函数可以通过以下3种方式调用://student.h #include<iostream> #ifnedf student_H_ #define student_H_ class student { private: char * id; char * grade; public: student(char *stu_id,char *stu_grade);//用输入的字符串创建对象 student(student &stu);//以一个student对象创建对象,实际上是复制构造函数 ~student();//析构函数 void showID(); char * getGrade(student & stu); }; #endif //student_implementation.cpp #include "student.h" #include<iostream> student::student(char *stu_id,char *stu_grade) { id=stu_id; grade=stu_grade; } student::student(student &stu) { id=stu.id; grade=stu.grade; }
每次创建对象时,都会使用构造函数,尤其是使用new时。使用new创建对象不需要再通过修改*指针名来修改对象,而是直接使用构造函数,代码如下:/* 类名 对象名=类名(参数值列表);//显式调用构造函数 类名 对象名(参数值列表);//隐式调用构造函数 类名 对象名1={参数列表};//列表初始化,C++11新特性 */ //user.cpp #include "student.h" int main { student Henry=student("000001","G301"); student Leo=studen("1531212","G301"); student Ben={"112233","G301"} return 0; }
· 默认构造函数/* 类名 *对象名=new 类名(参数列表) */ student s=new student("11223","G102"); delete s;//释放new分配的内存
默认构造函数可以通过默认参数构造函数、无参数构造函数两种方式之一声明、定义(但不要同时使用两种方式),代码如下:
/*声明:(只选择一种方式即可) 类名(带默认参数的列表);//声明默认构造函数的方式1 类名();//声明默认构造函数的方式2 定义: 类名(带默认参数的列表) { 同无默认参数的构造函数,不需要另写一个函数体; } 类名() { ...//给各个数据成员赋值。 } */ class student { private: char * id; char * grade; public: student(char *stu_id,char *stu_grade);//用输入的字符串创建对象 student(student &stu);//以一个student对象创建对象,实际上是复制构造函数 student(); ... }; #endif //student_implementation.cpp #include "student.h" #include<iostream> student::student(char *stu_id,char *stu_grade) { id=stu_id; grade=stu_grade; } student::student(student &stu) { id=stu.id; grade=stu.grade; } student()//采用方式2 { id="\0"; grade="\0"; }
- 复制构造函数
复制构造函数用于将一个对象复制到新创建的对象中,其本质是一个使用同类对象的常量引用作为参数的构造函数。复制函数只能用于初始化过程,而不能用于赋值过程(这是赋值运算重载)的功能。
复制函数的声明、定义代码如下:/*声明: 类名(const 类名 &参数名); 定义: 类名(const 类名 &参数名) { 成员对象1=参数名.成员对象1; .. } */ class student { private: char * id; char * grade; public: student(char *stu_id,char *stu_grade);//用输入的字符串创建对象 student(student &stu);//以一个student对象创建对象,实际上是复制构造函数 ... }; #endif //student_implementation.cpp #include "student.h" #include<iostream> student::student(char *stu_id,char *stu_grade) { id=stu_id; grade=stu_grade; } student::student(student &stu) { id=stu.id; grade=stu.grade; }
· 调用复制函数的情景
只要创建了类对象的副本,编译器都会调用复制函数。例如按值传递、返回对象(因此若可以返回引用,请返回引用,效率会更高)。
· 浅复制(shallow copy)与默认复制函数(隐式复制函数)
浅复制指的是只是复制值(例如,对于字符串而言,就是地址),而不新建单独的内存作为某一个值的副本。和构造函数一样,定义了复制函数(称为显式复制函数))以后,如果不定义一个默认复制函数(称为隐式复制函数,,编译器将会自己生成一个默认复制函数,此时进行的复制即为浅复制。
· 深度复制(deep copy)与显式复制函数
深度复制指不仅复制了值而且创建了一个量的副本,并将这个副本的地址赋给了相应的指针。在显式复制函数定义中使用new可以实现深度复制。
当类成员中有指向数据的指针时,最好声明一个显式的复制函数,防止数据本身被意外修改(如被析构函数释放内存)。
- 析构函数(destructor)
析构函数的作用在于释放对象的内存空间,若构造函数(包括复制函数)中使用了new或者new [],那么析构函数需要配套地使用delete或者delete [],以释放动态分配的内存。
析构函数具有以下特点:
#函数名是~类名(),没有参数,但返回值可有可无。
#函数体内容可有可无,但如果构造函数中有new或者new [],一定要用delete或者delete []释放内存。
#程序运行时,执行完对象所属的代码块后自动调用,如果没定义,编译器会自己生成一个默认析构函数。
#析构函数只有一个,而构造函数可以有多个。所以析构函数中需要综合考虑构造函数使用的是new还是new [],以决定是使用delete还是delete []。
析构函数的声明、定义如下/*声明: ~类名(); 定义: 返回类型(也可以忽略) 类名::~类名() { ...//例如delete或者delete []某个对象,也可以不写 } */ //student.h #include<iostream> #ifnedf student_H_ #define student_H_ class student { private: char * id; char * grade; public: ... ~student();//析构函数 ... }; #endif //student_implementation.cpp #include "student.h" #include<iostream> student::~student() { }
类的数据成员、一般成员函数
- 静态数据成员
静态数据成员具有以下特点:
#在程序运行期间,其一直存在,
#所有类创建的类对象共享一个静态数据成员。
#静态数据成员只能在定义的文件中初始化,不能在原型声明时初始化。
静态数据成员通过在类型前加static关键字实现:/*声明: static 类型名 变量名; 定义: 类型 类名::变量名=值; */ //OutputString.h class OutputString//输出的字符串类 { private: ... static int num_str;//用于统计运行时总共输出的字符串数量, ... public: ... } //OutputString.cpp int OutputString::num_str=0;//在实现文件中初始化
- 静态成员函数
同理,成员函数也可以声明为static的。
静态成员函数具有以下特点:
#不能通过类对象调用静态方法。若是在公有部分声明的,则可以通过类限定符(即类名::)调用
#静态方法不与特定对象关联,因此不能使用类的数据成员,只能使用静态数据成员
静态成员函数的声明、定义、调用代码如下:/*声明与定义:(直接在声明时定义) static 返回类型 函数名(参数列表){...} 调用: 类名::静态函数名(参数列表); */ //xx.h class xx { private: int i; static int num; ... public: ... static int count(){ return num;} }; //xx.cpp #include "xx.h" int xx::num=0//初始化静态成员 //user.cpp #include ... int n=xx::count();
- this指针
this是待创建对象本身的一个指针,其值是调用成员函数的对象,例如s1.show(),此时this指针指向的是s1。在类定义时,常使用this指针与箭头运算符->来指向类数据成员,或者直接返回调用对象本身的引用(*this)或者指针(this)。
- 保证不被修改的成员函数——const位于参数列表后的成员函数
const限定符不仅可以用于类型、变量,还可以用于函数,但位置只能放在参数列表以后。参数列表后的const表示确保函数不会修改对象(尤其是this指向的调用对象本身):/*声明: 返回类型 函数名(参数列表) const; 定义: 返回类型 函数名 (参数列表) const { } */ //xx.h class xx { private: ... public: ... void show() const; ... }
运算符重载与友元函数
如函数章节所述,函数重载可以使得名字相同的函数在接受不同参数时产生不同的响应。除了函数意外,C++还允许重载部分的运算符。
但重载运算符只允许运算符左侧的变量为类对象时才可以调用相应函数,需要通过定义友元函数来消除运算符左右两侧操作数的差异。但需注意的是,当参数类型均为同类对象时,实际调用将都能匹配这两种方式,会导致二义性,此时只能选择一种以避免编译错误。
- operator与运算符重载
运算符重载的本质还是定义一个函数,不过这个函数以operator 运算符为名。
· 运算符重载的声明、定义、调用
运算符重载的代码如下:
上面的代码t1+t2将匹配Time::operator(若还声明了友元,也不会匹配友元)。/*声明: 返回类型 operator 运算符(参数列表); 定义: 返回类型 类名::operator 运算符(参数列表) { } */ //Time.h class Time { private: int hours; int minutes; int seconds; public: Time(int h=0,int min=0,int sec=0); ~Time(); void show(); Time operator+(const Time & ti) const; ... }; //Time.cpp ... Time Time::operator+(const Time & ti) const { ... } //user.cpp Time t1=Time(1,1,1); Time t2(2,2,2); Time res_t2=t1+t2; Time res_t2=t1.operator+(t2);//等同于res_t2=t1+t2;
如上面的代码所示,运算符重载函数实际上也是需要用对象调用的。也正是因为此,当运算符左侧不是类对象时,将无法执行重载的运算。此时需要通过友元函数重新定义一个operator运算符函数。
· 可重载的运算符
最常重载的运算符还是+、-、<<、>>,尤其是后两者,可以将从文件输入,或者输出到文件中。
- 一类特殊的运算符重载——赋值函数
赋值函数用于将同类的(或者基类)对象的内容拷贝至一个对象,其是通过重载=运算符实现的。其声明、定义代码如下:/*声明: 类名 & operator=(const 类名 & 参数名); 定义: 类名 & 类名::operator=(const 类名 & 参数名) { ...//一般需要写出能实现深复制的代码 } */
之所以说它是特殊的,是因为调用它进行初始化后,调用赋值运算后,编译器会先调用复制函数生成一个临时对象,再将对象赋给待初始化的变量。这可能会导致浅复制的问题。
所以需要定义显式复制函数以解决深度复制的问题时,一般也需要显式定义赋值函数。
- 友元函数
友元函数是友元的一种(另外两种是友元成员函数、友元类),其与类的成员函数具有相同的访问控制权限。
友元函数具有以下特点:
#友元函数不是类成员函数,但是具有和类成员函数相同的访问权限,可以访问类的私有数据成员、私有方法。
#友元函数不能被继承
友元函数通过在类原型中使用friend声明,但在实现的文件中不需要写friend关键字,也不需要使用作用域解析运算符::(因为它不是类成员函数),其声明、定义的代码如下:
上述代码段中的Time res_t2=1.5+t1,由于+号左侧的操作数不是类对象,因此不匹配Time::operator+()函数,而是匹配友元函数operator+()。但是,若将友元参数调换顺序,那么Time res_t2=1.5+t1将没有函数可以匹配。/*声明://friend位置放哪里都可以 friend 返回类型 函数名(参数列表); 返回类型 friend 函数名(参数列表); 定义: 返回类型 函数名(参数列表) { } */ //Time.h class Time { private: int hours; int minutes; int seconds; public: Time(int h=0,int min=0,int sec=0); ~Time(); void show(); Time operator+(const Time & ti); friend Time operator+(double n,const Time & ti) ; ... }; //Time.cpp ... Time Time::operator+(const Time & ti) //Time::operator+() { ... } Time operator+(double n,const Time & ti) { Time res; res.hours=ti.hours+n; //若将友元函数声明放在类外并取消friend关键字,那么IDE将会报.hours不可访问 res.minutes=ti.minutes; res.seconds=ti.seconds; return res; } //user.cpp Time t1=Time(1,1,1); Time t2(2,2,2); Time res_t1=t1+t2;//调用Time::operator+() Time res_t2=1.5+t1;//调用友元函数
如果代码段中的友元的参数类型改为friend Time operator+(const Time &t1,const Time &t2),那么此时用户代码中的t1+t2将可以匹配Time::operator+()与友元函数,此时就会导致二义性的编译错误,此时就只能选择一种函数了。
- 一个经典的友元函数——重载<<运算符
重载<<运算可以用来输出到文件中,其一般用友元函数来实现,因为若用类重载运算实现,就会出现“类对象<<ostream类对象”的奇怪表达(因为<<也是重定向运算符,其方向应该指向输出),这样才能通过对象.oprator<<调用重载,而不是像“cout<<类对象”这样。
以Time类为例,重载<<运算符的代码如下://Time.h #include<iostream>//ostream类位于这 class Time { private: int hours; int minutes; int seconds; public: Time(int h=0,int min=0,int sec=0); ~Time(); Time operator+(const Time & ti); friend Time operator+(double n,const Time & ti) ; friend std::ostream operator<<(std::ostream &os,const Time &t); ... }; //Time.cpp ... Time Time::operator+(const Time & ti) //Time::operator+() { ... } Time operator+(double n,const Time & ti) { Time res; res.hours=ti.hours+n; //若将友元函数声明放在类外并取消friend关键字,那么IDE将会报.hours不可访问 res.minutes=ti.minutes; res.seconds=ti.seconds; return res; } std::ostream & operator<<(std::ostream &os,const Time &t) { os<<t.hours<<std::endl; os<<t.minutes<<std::endl; os<<t.seconds<<std::endl; return os; } //user.cpp Time t1=Time(1,1,1); cout<<t1; ---输出--- 1 1 1
类与其他类型之间的转换
类与其他类型的转换也需要通过函数去实现,具体方法是提供只有一个参数的构造函数(利用其他类型生成该类)以及转换函数(利用该类生成其他类型)。
- 将其他类型转换成某一类
· 构造函数的声明、定义、调用过程
需要提供一个构造函数,该构造函数以其他类型为唯一参数。在调用时,将通过类对象调用这个构造函数。其声明、定义、调用函数如下:
执行t1=1时,将利用Time(int h)生成一个临时的Time对象,后再将这个临时对象逐成员复制给t1(这里是深度复制)。这是隐式转换过程(即转换是自动进行的)。/*声明: 类名(待转换类型 参量名); 定义: 类名::类名(待转换类型 参量名) { ... } 调用: 类名 变量名=类名(其他类型的变量/值)//显式强制转换方式1 类名 变量名=(类名)其他类型的变量/值//显式强制转换方式2 类名 变量名=其他类型的变量//隐式转换方式 */ //Time.h #include<iostream>//ostream类位于这 class Time { private: int hours; int minutes; int seconds; public: Time(int h=0,int min=0,int sec=0); Time(int h);//将int类型转换成Time ~Time(); ... }; //Time.cpp ... Time(int h)//将int类型转换成Time { hours=h; minutes=0; seconds=0; } ... //user.cpp Time t1; t1=1;//隐式转换函数 cout<<t1;//调用重载<< ---输出--- 1 0 0
· explicit关键字
将构造函数作为转换函数会执行隐式转换,其可能导致意外的类型转换,例如:
#将类对象初始化为其他类型的值时;
#将其他类型的值赋给类对象时
#将其他类型值传递给接受类参数的函数时
#返回值为某一类的函数试图返回其他类值时
#在上述任意一种情况下,使用可转换为待转换类型的其他内置类型
为了避免危险的隐式转换,此时需要使用explicit关键字关闭隐式转换,当使用了explicit关键字后,只允许显式强制转换,其声明、定义、调用代码如下:
将某一类生成其他类型——转换函数/*声明: explicit 类名(待转换类型 参量名); 定义: 类名::类名(待转换类型 参量名) { ... } 调用:(显式强制转换) 类名 变量名=类名(其他类型的变量/值)//方式1 类名 变量名=(类名)其他类型的变量/值//方式2 */ //Time.h #include<iostream>//ostream类位于这 class Time { private: int hours; int minutes; int seconds; public: Time(int h=0,int min=0,int sec=0); explicit Time(int h);//将int类型转换成Time ~Time(); ... }; //Time.cpp ... Time(int h)//在实现中不需要加explicit关键字 { hours=h; minutes=0; seconds=0; } ... //user.cpp Time t1; t1=Time(1); cout<<t1;//调用重载<< ---输出--- 1 0 0
若需要将某一类生成其他类型,也需要编写相应的方法——转换函数。
转换函数具有以下特点:
#转换函数是类的方法
#不能指定返回类型(因为operator后的类型名已经告知了返回类型),但是定义中是需要return相关类型值
#不能有参数列表(因为是通过类对象调用的)
转换函数通过“operator 类名()”声明,代码如下:
转换函数也是可以使用explicit关键字的,含义同前。/*声明: operator 其他类名(类型 参量名); 定义: 类名::operator 其他类名(类型 参量名); { ... } 调用: 其他类名 变量名=类名(类型的变量)//显式强制转换方式1 其他类名 变量名=(其他类名)类型的变量//显式强制转换方式2 其他类名 变量名=类型的变量//隐式转换方式 */ //Time.h #include<iostream>//ostream类位于这 class Time { private: int hours; int minutes; int seconds; public: Time(int h=0,int min=0,int sec=0); explicit Time(int h);//将int类型转换成Time operator int() const; ~Time(); ... }; //Time.cpp ... Time(int h)//在实现中不需要加explicit关键字 { hours=h; minutes=0; seconds=0; } Time::operator int() const { return hours; } ... //user.cpp #include<iostream> int main { using namespace std; Time t1; t1=Time(1); cout<<t1;//调用重载<< int h=t1;//隐式转换 cout<<h<<endl;//正常的cout } ---输出--- 1 0 0 1
将对象作为返回值
可以返回对象、返回const对象、返回const对象的引用、返回非const对象的引用,这些返回类型的特点不同。
- 返回对象
适用于无法返回引用的情形,比如需要返回一个新建的自动变量,此时可以将这个自动变量作为返回值,最后会将这个自动变量复制给接受返回值的变量,调用完函数后自动变量内存会被自动释放。若此时返回一个指向自动变量的引用,那么该引用指向的内容在调用完以后就会被释放,所以只能返回对象。
- 返回const对象
适合需要防止返回值被修改的情况。如果不声明const,那么会发生返回值被覆盖的情景。例如某一类重载加法运算符的返回值是一个对象,obj1、obj2、obj3是该类的对象,obj1+obj2=obj3的语句就是合法的,因为执行完加法以后会返回一个临时对象,其值可以修改,最后用obj3的值覆盖了临时对象的值。(不过这个临时变量最后也会被自动释放)
- 返回const对象的引用
适合当函数返回调用的对象本身,并且不修改该对象值,以提高函数使用效率。
需要注意的是,若返回类型是const对象的引用,那么参数类型也应该是const对象或者对象引用。
- 返回非const对象的引用
适用于需要连续使用方法的情景,如重载赋值运算符(用于连续赋值)、重载<<运算符(用于连续输出)。重载赋值运算符返回非const对象引用,可以提高效率,并且保证最终结果还可以修改。重载<<运算符返回非const对象引用则是因为不能返回ostream类对象(因为该类没有公有的复制函数)。