C++面向对象高级编程
基于对象的程序设计
不带指针成员的类–Complex为例子
文件结构
头文件Complex.h
主要分为四个部分:
1.防卫式声明,防止头文件被重复包含
#ifndef __COMPLEX__
#define __COMPLEX__
/*
.
.
.*/
#endif
2.前置声明:声明头文件中用到的类和函数
#include<cmath>
class ostream;
class complex;
complex&
__doapl(complex* ths, const complex&r);
3.类声明:声明类的函数和变量,部分简单的函数可以在这一部分加以实现
class complex{
...
};
4.类定义:实现前面声明的函数
complex::function ....
访问级别
C++中有三种访问级别,
访问级别 | 意义 |
---|---|
private | 只能被本类函数访问 |
protected | 能被本类的函数和子类的函数访问 |
public | 可以被所有函数访问 |
class complex {
public:
complex(double r = 0, double i = 0) :
re(r), im(i) {};
complex& operator += (const complex&);
double real() const{ return re; }
double imag() const{ return im; }
private:
double re, im;
friend complex& __doapl(complex*, const complex&);
};
类的声明内可以定义多个不同级别的访问控制块
class complex{
public:
//public1访问控制块1....
private:
//private访问控制块1....
public:
//public访问控制块2....
}
函数设计
内联函数
在类声明定义的函数,自动成为inline
函数,在类声明外定义的函数,需要加上inline
关键字才能成为内联函数
//加上inline关键字
inline double imag(const complex& y) {
return y.imag();
}
inline
只是编程者给编译器的一个建议,在编译的时候未必会成为真正的内联函数,因此如果函数足够简单,我们把它声明为inline就好。
构造函数
与其他语言类似,C++构造函数也可以有默认实参,但C++构造函数的特殊之处在于列表初始化(initialization list)
complex(double r = 0, double i = 0) :
re(r), im(i) {};
//上述函数等同于
complex(double r = 0, double i = 0){
re = r;
im = i;
};
上面两种构造函数的效果是一样的,但是列表初始化的效率会更高,应该尽量使用列表初始化,默认实参可以让类的使用者更好的创建对象。
complex c1(2, 1);
complex c2; //complex(0, 0)
complex* p = new complex(4); //complex(4, 0)
默认实参不能引起歧义,否则会让编译失败
常量成员函数
如果成员函数中不改变成员变量,应该加入const
来加以修饰
double real() const{ return re; }
double imag() const{ return im; }
如这类函数不加以修饰,那么常量函数不能调用这些函数:
const complex c(2, 1); //定义变量常量
c.real(); //若real()函数不加以const修饰,则编译时报错
参数的值传递和引用传递
为了提高效率,使用引用传递参数,避免参数的复制,若不希望在函数体内对输入参数进行修改,可以使用const
修饰输入参数。
函数的参数应该尽量使用引用传递!
返回值的值传递和引用传递
为了提高效率,如果返回的值是原本就存在的对象,则应以引用的形式返回,如果函数返回的值是临时变量,则只能通过值传递返回。
complex& operator += (const complex&);
ostream& operator<<(ostream &os, const complex& x) {
return os << "(" << real(x) << ","
<< imag(x) << ")";
}
友元
友元函数不受访问级别的控制,可以自由地访问对象的所有成员。
friend complex& __doapl(complex*, const complex&);
//自由访问private类型数据
complex& __doapl(complex* ths, const complex& r) {
ths->re += r.re;
ths->im += r.im;
return *ths;
}
同一个类的各个对象互为友元,因此可以在类定义内访问其他对象的私有变量
class complex{
public:
int func(const complex& param) {
return param.re + param.im;
}
private:
double re, im;
}
操作符重载
在C++中的操作符重载有两种形式,一类是在类内声明public函数实现操作函数重载,操作符是作用在左操作数上的,另一种是在全局函数实现操作符重载。
例如:对于如下语句,有两种方式可以实现操作符+
的重载
complex c1;
c1 + 2;
1.在类内声明public
函数complex::operator + (int)
2.在类声明全局函数complex operator + (const complex&, int)
这两种方式都可以实现操作符重载,为了方便调用类的用户使用,不同操作符使用不同的方式进行重载
在类内声明public
函数重载+=
重载+=
函数的complex::operator + (const complex& r)
的输入参数和输出参数均使用引用传值,输入参数不应该被改动,因此使用const
修饰。
输出参数类型为complex&
,这是为了支持多个+=
操作符串联起来,假设返回值类型为void
,也支持c2 += c1
这样的操作,但是不支持c3 += c2 += c1;
这样的操作
inline complex& complex::operator+=(const complex& r) {
return __doapl(this, r);
}
complex& __doapl(complex* ths, const complex& r) {
ths->re += r.re;
ths->im += r.im;
return *ths;
}
函数体调用友元函数实现功能,第一个参数是成员函数内隐含的this
指针,第二个参数是接受重载函数的参数,该参数不会被改变,使用const
进行修饰。
使用引用传递参数和返回值的好处在于传送者不需要知道是否以引用形式接受,只需要和值传递一样写代码就行,不需要进行改动。
在类外声明或函数重载+
考虑到+操作的用法
complex c1(2, 1);
complex c2;
c2 = c1 + c2; //complex + complex
c2 = c1 + 5; //complex + int
c2 = 7 + c1' // int + complex
因为重载操作符的成员函数是作用在左操作函数上的,若使用类内声明public
函数重载操作符的方法,就不能支持第三种用法,因此要使用类外声明函数重载+
运算符
这三个函数返回的是局部对象(local object),不能使用引用传递返回值,因为退出函数的时候对象就会被销毁。
在类外声明重载<<
与重载+
的考虑方法类似,通常习惯的用法是cout << c1
,而并非c1 << cout;
所以要在类外重载。
同时,有考虑到有cout << c1 << c2 << c3;
这种用法,所以重载函数的返回值是ostream&
而并非void
;
小结
在编写类的代码的时候,可以通过以下五点看你的类是否大气:
1.构造函数中使用列表初始化(initialization list)为成员变量赋值
2.常量成员函数用const
修饰
3.参数传递的时候尽量用引用传递,如果不会修改传入的参数,那么应该以const
进行修饰。
4.返回的值不是局部变量,返回的值最好以引用传递的方式返回。
5.数据要放在private
中,函数放在public
中。
带有指针成员的类–以String为例子
头文件
,String类定义在头文件中,结构与complex.h
相似
类声明如下,使用指针成员变量m_data
管理string类中的字符串数据。
class String {
public:
String(const char* cstr = 0);
String(const String& str);
String& operator= (const String& str);
~String();
char* get_c_str() const { return m_data; }
private:
char* m_data;
};
三类特殊的函数:拷贝构造函数、拷贝赋值函数、析构函数
对于不带指针的类,这三个函数可以使用编译器默认为我们生成的版本,但是编写带有指针的类的时候要重新定义这三个特殊的函数
构造函数与析构函数
构造函数和析构函数执行数据的深拷贝和释放,
inline
String::String(const char* cstr = 0) {
if (cstr) {
m_data = new char[strlen(cstr) + 1];
strcpy(m_data, cstr);
}
else {
m_data = new char[1];
*m_data = '\0';
}
}
inline
String::~String() {
delete[] m_data;
}
值得注意的是,使用delete []
操作符释放数组内存,如果直接使用delete
虽然能够通过编译,但是有可能会产生内存泄漏
附:
1.strlen()函数用来计算字符串的长度,其原型为:
unsigned int strlen (char *s);
【参数说明】s为指定的字符串。
strlen()用来计算指定的字符串s 的长度,不包括结束字符"\0"。
2.char* strcpy(char* strDestination, const char* strSource);
strcpy() 会把 strSource 指向的字符串复制到 strDestination。
必须保证 strDestination 足够大,能够容纳下 strSource,否则会导致溢出错误
拷贝构造函数和拷贝赋值函数
拷贝构造函数和拷贝赋值函数的使用场景不同,下面程序的拷贝3虽然使用了=
赋值,但是因为是在初始化过程中使用的,所以调用的是拷贝构造函数。
String s1 = “hello”;
string s2(s1); //拷贝1:调用拷贝构造函数
string s3;
s3 = s1; //拷贝2:调用拷贝赋值函数
string s4 = s1; //拷贝3:调用拷贝构造函数
拷贝构造函数的实现比较简单,直接调用友元对象的数据指针进行拷贝即可。
inline
String::String(const String& str) {
m_data = new char[strlen(str.m_data) + 1];
strcpy(m_data, str.m_data);
}
拷贝赋值函数中要检测自我赋值,这不仅仅是为了考虑效率,也是为了防止出现Bug。
inline
String& String::operator=(const String& str) {
if (this == &str)
return *this;
delete[] m_data;
m_data = new char[strlen(str.m_data) + 1];
strcpy(m_data, str.m_data);
return *this;
}
堆栈与内存管理
栈(stack),是作用于某个作用域的一块内存空间,例如当你调用函数,函数本身就会形成一个栈,用来放置它所接收的参数以及返回地址,在函数本体内声明任何变量,其使用的内存块都取自于上述栈。
堆(heap),是指由操作系统提供的一块global内存空间,程序可以动态分配从其中获得若干区块。
1.stack object生存周期
class Complex{...};
//...
{
Complex c1(1, 2);
}
程序中的c1就是stack object,生命周期在作用域(大括号)结束之际结束,这种域内对象又被称为auto object,因为它会被自动清理
2.static object生存周期
class Complex{...};
//...
{
static Complex c2(1, 2);
}
程序中的c2是static object,生命周期在作用域结束以后仍然存活,直到整个程序完全结束。
3.global object的生命周期
class Complex{...};
//...
Complex c3(1, 2);
int main(){
...
}
程序中的c3就是global object,其生命在整个程序结束之后才结束,也可以是视为另一种static object,其作用域是整个程序
4.heap object生命周期
class Complex{...};
//...
{
Complex* p = new Complex;
//...
delete p;
}
程序中的p指向的对象就是heap object,其生命周期在它被delete之际结束,如果退出作用域的时候忘记delete指针p,那么会发生内存泄漏,即p所指向的heap object仍然存在,但是指针p的生命周期却结束了,作用域之外再也无法操作p所指向的heap object。
new 和 delete过程中的内存分配
1.new操作先分配内存,再调用构造函数
2.delete先调用析构函数,再释放内存
带有指针的类的new与delete:
VC中对象在 debug 模式和 release 模式下的内存分布如下图所示,变量在内存中所占字节数必须被补齐为16的倍数,红色代表 cookie 保存内存块的大小,其最低位的 1 和 0 分别表示内存是否被回收
数组中的元素是连续的,数组头部4个字节记录了数组长度:
根据数组在内存中的状态,自然可以理解为什么new[]
和 delete[]
应该配对使用了: delete
操作符仅会调用一次析构函数,而 delete[]
操作符依次对每个元素调用析构函数.对于 String 这样带有指针的类,若 将 delete[]
误用为 delete
会引起内存泄漏
Static成员
对于类来说, non-static
成员变量每个对象均存在一份, static
成员变量、 non-static
和 static
成员函数在内存中仅存在一份.其中 non-static
成员函数通过指定 this
指针获得函数的调用权,而 static
函数不需要 this 指针即可调用
static成员函数可以通过对象调用,也可以通过类名调用
class Account{
public:
static double m_rate;
static void set_rate(const double& x){ m_rate = x; }
};
double Account::m_rate = 8.0;
int main(){
Account::set_rate(5.0);
Account a;
a.set_rate(7.0);
}
static
成员变量需要在类声明体外进行初始化
面向对象程序设计–类之间的关系
类之间的关系有复合、委托、继承三种关系。
类之间的关系
复合(composition)
复合表示一种has a
的关系,STL中的queue
的实现就是使用了复合的关系,这种结构也被称为adapter模式。
复合关系下构造由内到外,析构由外到内:
委托(aggregation;composition by reference)
委托将类的定义与类的实现分隔开来,也被称为编译防火墙.
继承
继承表示一种 is-a
的关系,STL中 _List_node 的实现就使用了继承关系
继承关系下构造由内而外,析构由外而内:
虚函数
成员函数有3种:非虚函数、虚函数和纯虚函数
1.非虚函数(non-virtual):不希望子类重新定义的函数
2.虚函数(virtual):子类可以重新定义的函数,且有默认定义
3.纯虚函数:子类必须重新定义的函数,没有默认定义
class Shape {
public:
virtual void draw() const = 0;
virtual void error(const std::string& msg);
int objectID() const;
};
class Recrangle : public Shape {};
class Elipse :public Shape {};
使用虚函数实现框架: 框架的作者想要实现一般的文件处理类,由框架的使用者定义具体的文件处理过程,则可以用虚函数来实现
将框架中父类 CDocument
的 Serialize()
函数设为虚函数,由框架使用者编写的子类 CMyDoc
定义具体的文件处理过程,流程示意图和代码如下:
面向对象设计例子
使用委托+继承实现Observe模式
使用Observer
模式实现多个窗口订阅同一份内容并保持实时更新
类结构图如下: