C++ 面向对象程序设计


本章内容概述

本文用于笔者在学习侯捷老师《C++ 面向对象高级开发》课程中的笔记记录,在本章中,通过实现两个 Class ,分别为不含指针的复数类 [Complex] 和含指针的字符串类 [String] 为例,讲解在面向对象编程中通常需要注意的关键点,进而提高我们的代码质量。


一、C++编程简介

学习本门课程应当具备的基本条件以及C++的基本理解,如变量、类型、作用域、循环等。课程的目标是希望能够培养出一种正规的,大气的编程习惯,使用良好的方式编写C++ Class,无论是without pointer还是with pointer,亦或是基于对象和面向对象,都有着许多对应的需要注意的关键点。

首先我们要理解 Class 的概念的产生,我们希望将数据和对应的处理数据的方法封装在一起,从而提高数据的私密性,那么,封装后的整体就可以称之为 Class。Class在构成数据上可以大致分为两类,一种是不带指针的,一种是带指针的,因此课程主要也对这两类进行分类讲解。

二、Complex 复数类

1.Point Directory

  • 头文件防卫式声明
  • 类模板应用
  • 内联函数应用
  • 成员变量访问权限
  • 构造函数的初始化列表及函数重载
  • 常量成员函数,常对象仅能调用常量成员函数
  • 友元函数,同一个类的各个对象互为友元
  • 操作符重载
  • 参数传递尽可能全部通过引用传递,尽量传为常量引用
  • 临时对象 complex(2, 2),仅生存在当前作用域,用后立刻释放

2.头文件

在编写头文件时需要注意的第一点,要对头文件进行防卫式声明,代码如下:

#ifndef __COMPLEX__
#define __CEMPLEX__

//头文件内容
...

#endif

之所以要进行防卫式声明,是为了防止在后期导入头文件时因头文件的相互嵌套而导致出现重定义的情况,代码如下:

//complex.h
#include"complex.cpp"
...
//test.h
#include"complex.h"
#include"test.cpp"
...

如果 complex.h 中没有进行防卫式声明,那么当主文件需要同时包含 test.h 和 complex.h 时就会出现问题,代码如下:

//main.cpp
#include"complex.h"
#include"test.h"
...

//"complex.h"重定义,因为在 main 中包含了两次"complex.h"

防卫式声明的作用是通过定义宏来判断头文件是否已经被包含的,从而避免一个头文件被嵌套多次包含导致重定义。

3.Complex Class 实现

首先对“复数”进行声明,并在需要注意的点使用注释标注,代码如下:

class complex
{
public:
	//构造函数,充分利用默认参数,无返回值类型,可重载
	//尽可能使用列表初始化,仅构造函数可以
    complex(double r=0, double i=0): re(r), im(i) {}

	//pass by reference,提高效率,在不修改的情况下要设定为常量引用 const
    complex& operator += (const complex&);

	//定义在类内部的函数会被设定为 inline 函数,更快捷
	//定义在类外部的函数可以通过关键字建议设定为 inline 函数,但取决于编译器
    double real() const
    {
        return re;
    }

	//对外提供私有成员的访问接口,仅供访问,外界无法修改数据
    double imag() const
    {
        return im;
    }

	//左移运算符重载
	//改变了 cout 状态,因此无法设定为 const
	ostream& operator << (ostream& cout, const complex& x)
	{
		cout << x.real() << " " << x.imag() << endl;
		return cout;
	}

//类内数据理应全部设定为私有属性,禁止外部访问
private:
	//如果需要设计多种数据类型的complex,那么可以利用模板设计
    double re,im;

	//友元访问
    friend complex& __doapl(complex* ths, const complex& r);
};

inline complex& __doapl(complex* ths, const complex& r)
{
	ths->re += r.re;
	ths->im += r.im;
	return *ths;
}

纵观整段代码,整个类的书写条理清楚,对每一个需要关注的点逐一分析:

对于不同数据类型的类的实现,我们优先考虑类模板的应用,在实例化对象时确定数据类型,可以有效减少代码量,代码如下:

template<class T>
class complex
{
public:

private:
    T re, im;
};

内联函数的应用值得一提,它是为了提高程序效率的产物,程序在编译器编译阶段,编译器将程序中出现的内联函数的调用表达式用内联函数的函数体直接进行替换,相对于其他函数在运行时才被替代,这样的方式加快了程序的执行速度,但是却造成了空间需求的增加,因此内联函数要求函数体较为短小,本质是利用空间换取时。

对于内联函数,编译器不允许内部存在循环或开关语句,以及一些特殊函数如析构函数、递归函数,虚函数因其存在多态性一般不会被内联,同时函数定义必须出现在第一次调用之前,而类结构中类内部定义的函数会被自动设置为内联函数。

类中成员变量和成员函数的访问权限尤其重要,一般不建议对外开放成员属性的修改权限,仅对外提供访问接口,对于一些外部不会调用的函数也可以设置为私有权限。

构造函数,应当充分利用函数默认参数和函数重载的功能,确保每一个实例化的对象初始化合法性,值得关注的是应当成分利用构造函数特有的初始化列表,在类内部,实例化对象时分为两步,分别是初始化和根据构造函数对各个成员变量依次赋值。值得一提的是,构造函数一般不会设置为私有函数,但是在特殊的设计模式单例设计模式Singleton下,会被设置为私有成员函数,详细的内容会在设计模式中进行分析。

初始化列表的好处就在于可以在第一步对象的初始化阶段就对各个变量完成赋值,无需额外分配内存后再进行赋值,因此速度更快,推荐使用。在一些情况下,如常量成员或引用,因其只能初始化而无法赋值,所以必须使用列表初始化。在使用初始化列表时要注意,初始化列表可以指定调用基类的构造函数,但是初始化列表无法指定初始化顺序,初始化顺序是按照成员定义顺序进行的。

常量成员函数,在成员函数不会修改成员变量的情况下,应当尽可能将函数设置为常量成员函数,这样既保证了不会误修改成员变量,同时使得实例化的常对象可以合法使用函数,因为常对象仅可调用常量成员函数。对于常量成员函数,可以理解为将其绑定了一个常对象的指针,因此无法在对内部数据进行修改。

友元函数,可以通过设置函数为友元函数从而使得函数可以访问类内部私有成员,但是友元函数无 this 指针,因此调用时需要对象作为参数,但不需要通过对象或类进行调用。友元函数虽然较为方便,但是却也导致封装后的类产生了缺口,慎重使用。

操作符重载是很复杂的一块内容,随后单独讨论分析,此处先行略过。

参数传递不仅在类中需要注意,在任何一个函数中都值得关注。首先分析参数传递与返回值的形式,尽可能使用引用传递,可以提高参数传递的效率,同时接受者无需知晓参数传递方式,可以按照值传递的方式接收,简单便捷,代码如下:

//返回值和参数列表均采用引用的形式传递
inline complex& __doapl(complex* ths, const complex& r)
{
	ths->re += r.re;
	ths->im += r.im;
	return *ths;
}

在特殊情况下,无法利用引用传递,引用本质是指针,如果函数体内的变量因离开了作用域而被销毁,则返回的引用将是非法的。因此,返回值采用引用类型必须是已经存在的,并非是在函数体内部定义的变量,如临时变量,仅生存在当前作用域,离开后立刻释放。以下情况就无法返回引用:

inline complex operator + (const complex& x, const complex& y)
{
	return complex(x.real() + y.real(), x.imag() + y.imag());
}

三、String 字符串类

1.Point Directory

  • Big Three,三个重要函数
  • 构造函数和析构函数
  • 拷贝构造
  • 拷贝赋值
  • output函数

2.AString Class 实现

字符串类的实现,需要在类内添加指针型成员变量,因此,不同于复数类,有些函数我们需要额外关注,首先分析字符串类实例化需求,代码如下:

int main()
{
	//默认构造
	AString s1();
	
	//有参构造
	AString s2("hello");
	
	//拷贝构造
	AString s3(s1);
	
	//左移运算符重载
	cout << s3 << endl;
	
	//赋值运算符重载
	s3 = s2;
	cout << s3 << endl;
}

不同于复数类,字符串的拷贝构造与赋值操作因涉及到指针,利用指针指向一个字符串,因此编译器默认的函数不能完成需求,必须重写对应函数,代码如下:

class AString
{
public:
	AString(const char* cstr = 0);
	
	//Big Three,三个重要函数
	AString(const AString* str); //拷贝构造
	AString& operator = (const AString& str); //赋值运算符重载,拷贝复制
	~AString(); //析构函数

	//对外提供访问接口
	char* get_c_str() const
	{
		return m_data;
	}
	
private:
	char* m_data;
};

构造函数,类内仅有一根指针,因此在构造函数内部需要额外申请空间存放字符串,代码如下:

inline AString::AString(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 AString::~AString()
{
	//释放堆区内存
	delete[] m_data;
}

在下列代码中,分析内存状态,代码如下:

{
	AString* p = new AString("hello");
	delete p;
}

在初始化对象指针时,首先向堆区申请空间定义指针 p ,然后使用该指针调用类的构造函数,在构造函数内部再一次向堆区申请空间指向 m_data ,存放字符串。在释放指针的过程中,首先释放指针指向的内容,因此会调用 AString 的析构函数,在析构函数内会释放类内成员指针 m_data 指向的空间,然后再释放指针 p 。

拷贝构造,类内存在指针成员变量,必须重写拷贝构造,编译器默认的拷贝构造只做简单的复制,指针指向也被直接拷贝,导致实例化的对象间存在冲突,造成内存泄露,属于浅拷贝。重写拷贝构造,以完成深拷贝,代码如下:

inline AString::AString(const AString& str)
{
	//类的各个对象之间互为友元,可以直接访问私有成员
	m_data = new char[strlen(str.m_data) + 1];
	strcpy(m_data, str.m_data);
}

拷贝赋值,即重载赋值运算符,与拷贝构造一致,同样需要考虑深浅拷贝的问题,代码如下:

inline AString& AString::operator = (const AString& 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;
}

左移运算符重载,必须设定为全局函数,否则无法满足链式编程,代码如下:

inline ostream& AString::operator << (ostream& cout, const AString& str)
{
	cout << str.get_c_str();
	return cout;
}

四、New and Delete

1.New 函数

在前文中我们谈到,利用 new 函数在堆区开辟空间,分析一下 new 函数的执行逻辑,代码如下:

complex* pc = new complex(1, 2);

这段代码是在向堆区申请空间,以存放 pc 指向的 complex 类对象,在编译器则会将其转化为如下方式:

//申请合适大小的内存
void* pc = operator new(sizeof(complex)); //内部调用 malloc

//强制类型转换
pc = static_cast<complex>(mem);

//调用构造函数
pc->complex::complex(1, 2);

2.Delete 函数

delete 函数专门用于释放 new 函数在堆区开辟的空间,如果在堆区开辟的空间没有被合法释放,则会导致内存泄露。同样,分析 delete 函数执行逻辑,代码如下:

AString* ps = new AString("hello");

delete ps;

上述代码在堆区开辟了空间存放 ps 指向的字符串对象,然后释放该空间,在编译器内部将被转化为:

AString::~AString(ps);
operator delete(ps);

即,首先调用字符串对象的析构函数,在析构函数内部将会释放类内的堆区空间,然后在调用系统内部的 operator delete 函数,释放 ps 指向的内存。

需要注意的是,array new 函数必须搭配 array delete 函数使用,否则会造成内存泄露,详细内容不在此展开,后续课程会详细分析。

五、扩展补充

1.Static 静态

在类内部,每个实例化的对象都有一份自己的成员数据,但是成员函数却仅有一份,调用时,函数通过判断调用对象的 this 指针来确认是哪个对象调用的函数。

当某个成员变量被声明为静态成员变量后,那么类实例化的所有对象都仅有一份该数据,可以通过每个类对象调用,也可以通过类名调用。静态成员变量只能在类外进行初始化,类内声明,因为,如果静态成员变量在类内,即构造函数中初始化,就会导致每个对象都包含该静态成员变量,不符合静态成员变量性质。与之类似的还有常量成员变量,仅能在初始化列表处赋值。但是, const 类型的 static 成员变量可以在类内进行赋值。

2.Namespce 命名空间

对代码进行封锁,包装在一个命名空间内,防止代码之间产生冲突,代码如下:

namespace std
{
	...
}

通过上述方式可以将程序各处的代码封装在 std 内,防止产生冲突,在调用的时候,解开封装即可,代码如下:

//全部打开 std 封装,可以任意调用内部内容,using directive
#include<iostream>
using namespace std;

int main()
{
	cin>>...;
	cout<<...;
	return 0;
}

全部打开封装后即可任意调用,也可以仅打开一部分,代码如下:

//打开 std 部分封装,using declaration
#include<iostream>
using std::cout;

int main()
{
	std::cin>>...;
	cout<<...;
	return 0;
}

或者不打开封装,直接通过命名空间调用内部,代码如下:

//不打开 std 封装
#include<iostream>

int main()
{
	std::cin>>...;
	std::cout<<...;
	return 0;
}

六、类之间的关系

1.Composition 复合

一个类的部分或全部功能可以通过调用另一个类已经实现的功能实现,即在一个类内包含另一个类的对象,称为复合,可以理解为 “has a”, 代码如下:

template<class T, class Sequence = deque<T>>
class queue
{
	...
protected:
	Sequence c; //底层容器
public:
	bool empty() const {return c.empty()};
	...
	void pop() { c.pop_front() };
};

分析复合类对象间的构造和析构关系,简而言之,构造由内而外,析构由外而内,代码如下:

//构造函数
Container::Container(...): Component() {...}

构造函数,首先应当构造内部类,才稳定。编译器只会调用默认构造,因此有特殊需求的应当在初始化列表中指定构造函数。

//析构函数
Container::~Container(...): {... ~Component() ...}

析构函数,要先析构外部类的数据,再去析构内部,才稳定。

2.Delegation 委托

一个类内部存在指针指向另一个类,两个类通过指针相连,称为委托,可以理解为"Composition by reference",随时可以通过指针调用,代码如下:

// file String.h
class StringRep;
class String
{
public:
	String(){};
	~String(){};
private:
	StringRep* rep; // pimpl
};

委托,是一种很有名的设计方式"pimpl",被称为编译防火墙,在主类内部有着指向别的类的指针,这些指针可以指向不同的类,从而满足不同的需求,主类内部只需要具备可以自由切换指针的功能即可,但是也会导致代码量增加。

3.Inheritance 继承

继承,是面向对象最常见的特性之一,一个类继承了另一个类的全部内容,并在此基础上可以进行扩展,称为继承,可以理解为"is a",代码如下:

class _List_node_base
{
	_List_node_base* _M_next;
	_List_node_base* _M_prev;
};

template<typename _Tp>
class _List_node: public _List_node_base
{
	_Tp _M_data;
}

子类的内部有父类的成分,因此,构造和析构的顺序与复合相同,代码如下:

//构造函数
Derived::Derived(...): Base() {...}

//构造函数
Derived::~Derived(...): {... ~Base() ...}

七、虚函数与多态

1.虚函数

在面向对象的继承特性中,函数可以分为三类:非虚函数 non-virtual,虚函数 virtual,纯虚函数 pure virtual,代码如下:

class shape
{
public:
	// 纯虚函数,子类必须重新定义,无默认定义
	void draw() const = 0;

	// 虚函数,希望子类重新定义,已有默认定义
	void error (const std::string& msg);

	// 非虚函数,子类无法重新定义
	int objectID() const;
}

2.多态,复合与委托

当一个子类与其他类存在复合关系,构造和析构的顺序可以通过一下代码进行测试:

class adata_base
{
public:
	adata_base() { cout << "ab ctor" << endl; }
	~adata_base() { cout << "ab dtor" << endl; }
};

class adata_help
{
public:
	adata_help() { cout << "ah ctor" << endl; }
	~adata_help() { cout << "ah dtor" << endl; }
};

class adata :public adata_base
{
public:
	adata(int v){ cout << "a ctor" << endl; }
	~adata() { cout << "a dtor" << endl; }
private:
	adata_help x;
};

实例化一个 adata 对象后,观察显示结果:

ab ctor
ah ctor
a  ctor
a  dtor
ah dtor
ab dtor

得到结论,先构造基类,再构造复合类,最后构造子类。

当复合关系转变为委托关系时,构造和析构顺序可以如下测试:

class adata_base
{
public:
	adata_base() { cout << "ab ctor" << endl; }
	~adata_base() { cout << "ab dtor" << endl; }
};

class adata_help
{
public:
	adata_help() { cout << "ah ctor" << endl; }
	~adata_help() { cout << "ah dtor" << endl; }
};

class adata :public adata_base
{
public:
	adata(int v){ cout << "a ctor" << endl; }
	~adata() { cout << "a dtor" << endl; }
private:
	adata_help* x;
};

如果在子类构造函数中没有为委托类的指针申请空间,那么就不会调用委托类的构造函数,代码如下:

ab ctor
a  ctor
a  dtor
ab dtor

在子类构造函数中为委托类申请堆区空间,并在子类析构函数中释放,代码如下:

class adata :public adata_base
{
public:
	adata(int v)
	{
		cout << "a ctor" << endl;
		x = new adata_help();
	}
	~adata()
	{
		cout << "a dtor" << endl;
		delete x;
	}
private:
	adata_help* x;
};

可以观察到,构造时依然是先调用基类构造函数,再调用子类构造函数,最后再调用委托类构造函数,析构时先析构子类,再析构委托类,最后析构基类,代码如下:

ab ctor
a  ctor
ah ctor
a  dtor
ah dtor
ab dtor

当一个类存在继承、复合、委托三种关系时,构造和析构的顺序可以进行测试,代码如下:

class adata_base
{
public:
	adata_base() { cout << "ab ctor" << endl; }
	~adata_base() { cout << "ab dtor" << endl; }
};

class adata_help
{
public:
	adata_help() { cout << "ah ctor" << endl; }
	~adata_help() { cout << "ah dtor" << endl; }
};

class adata_together
{
public:
	adata_together() { cout << "at ctor" << endl; }
	~adata_together() { cout << "at dtor" << endl; }
};

class adata :public adata_base
{
public:
	adata(int v)
	{
		cout << "a ctor" << endl;
		x = new adata_help();
	}
	~adata()
	{
		cout << "a dtor" << endl;
		delete x;
	}
private:
	adata_together y;
	adata_help* x;
};

运行程序,可以轻松得出结论:

ab ctor
at ctor
a  ctor
ah ctor
a  dtor
ah dtor
at dtor
ab dtor

本章总结

本章内容,了解了基于对象和面向对象的基本内容,面向对象的设计任重而道远,类之间抽象的思考十分复杂,在下一章,会继续探讨面向对象高级编程,学习更高阶的知识。

最后,我是Alkaid#3529,一个追求不断进步的学生,期待你的关注!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Alkaid3529

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值