C++ 程序设计兼谈对象模型

  • Post author:
  • Post category:其他





本章内容概述


本文用于笔者在学习侯捷老师《C++ 程序设计兼谈对象模型》记录笔记,在上一门课《C++面向对象程序设计》基础上,进一步的深入学习。万丈高楼平地起,勿在浮沙筑高台,基础要扎实,对基本知识的掌握必须深入一些,未来才能更好地运用。


所谓,革命尚未成功,同志仍需努力,本章将对上门课未涉及的知识点作补充讲解,包括泛型编程和面向对象深入的知识,还会介绍一些C++11的新特性。




一、面向对象


在上门课的结尾,留下了部分更深入的细节,在此对这些细节进行逐一深入分析,探讨使用的注意事项。



1.Point Directory

  • operator type() 转换函数
  • explicit 关键字
  • Pointer-like class
  • Funcition-like class



2.类的类型转换



转换函数

,一种特殊的类成员函数,用于实现类之间的相互转换,利用转换函数可以将一个对象转换为另一种类型的数据,代码如下:

class Fraction
{
public:
	Fraction(int num, int den = 1) :m_numerator(num), m_denominator(den) {}

	operator double() const
	{
		return ((double)this->m_numerator / this->m_denominator);
	}

	double operator+ (Fraction& f)
	{
		return ((double)this->m_numerator / this->m_denominator) + f;
	}
	
	//friend ostream& operator<< (ostream& cout, Fraction& f);
private:
	int m_numerator; //分子
	int m_denominator; //分母
};


在转换函数作用下,编译器会在需要转换类类型时,调用转换函数,将类合理地转换为对应形式通过编译,代码如下:

Fraction f1(1, 2);
Fraction f2(4, 5);

cout << f1 + f2 << endl;
cout << f1 << endl;

//输出结果:
1.3
0.5


观察输出结果,在没有合适的输出 Fraction 类的情况下,编译器自动将其转换为 double 类进行输出,同时在重载加法运算符中,Fraction 类也被自动转换了类型。


重载左移运算符后,观察结果,代码如下:

ostream& operator<< (ostream& cout, Fraction& f)
{
	cout << f.m_numerator << " " << f.m_denominator << endl;
	return cout;
}

Fraction f1(1, 2);
Fraction f2(4, 5);

cout << f1 + f2 << endl;
cout << f1 << endl;

//输出结果:
1.3
1 2


显而易见,f1 的输出结果发生了变化,重载左移运算符后,f1 无需转换类型也可通过编译,因此结果不同。


在使用转换函数时需要注意,转换函数必须作为成员函数,无需也不可以指定返回类型,不能有参数。同时,特定参数的构造函数,也会产生和转换函数相同的效果。



explicit

,适用于防止类构造函数的隐式自动转换。当类的构造函数满足特定条件时,编译器在某些情况下会自动将数据利用构造函数转换为一个临时对象,代码如下:

class Fraction
{
public:
	Fraction(int num, int den = 1) :m_numerator(num), m_denominator(den) {}

	operator double() const
	{
		return ((double)this->m_numerator / this->m_denominator);
	}

	double operator+ (Fraction& f)
	{
		return ((double)this->m_numerator / this->m_denominator) + f;
	}

	friend ostream& operator<< (ostream& cout, Fraction& f);
private:
	int m_numerator; //分子
	int m_denominator; //分母
};

ostream& operator<< (ostream& cout, Fraction& f)
{
	cout << f.m_numerator << " " << f.m_denominator << endl;
	return cout;
}


在 Fraction 类的构造函数中,默认分母为1,符合自然数学规范,同时意味着,仅需一个整型数据,即可实例化一个 Fraction 类的对象,调用以下代码并观察结果:

Fraction f1(1, 2);
Fraction f2(4, 5);
cout << f1 + f2 << endl;
cout << f1 + 1 << endl;
cout << f2 + 2 << endl;

//输出结果:
1.3
1.5
2.8


观察到,在没有重载 Fraction 类与整型数据的加法的情况下,编译器依然得到了对应答案并输出,这是因为,编译器为了通过程序编译,自动帮我们调用了 Fraction 类的构造函数,发现可以进行隐式类型转换从而通过编译。但是,这种隐式类型转换可能并不是本意,为了防止这种情况在不被知晓的情况下出现,可以对类构造函数添加关键字 explicit,从而杜绝编译器自作主张进行隐式类型转换,代码如下:

explicit Fraction(int num, int den = 1) :m_numerator(num), m_denominator(den) {}


对于拷贝构造,如果被声明为 explicit ,则该类对象不可被隐式调用、值传递或返回,代码如下:

explicit Fraction(const Fraction& f)
	{
		this->m_denominator = f.m_denominator;
		this->m_numerator = f.m_numerator;
	}

Fraction* self(Fraction* f)
{
	return f;
}

Fraction& self(Fraction& f)
{
	return f;
}
	
Fraction self(Fraction f)
{
	// 编译错误:类没有适当的复制构造函数
	return f;
}


引用传递、指针传递都正常编译通过,但值传递无法通过。


当且仅当类构造函数存在隐式类型转换风险时可以通过 explicit 关键字规避,特别地,在类内部还存在转换函数时应当考虑使用避免二义性,同时,拷贝构造一般无需声明 explicit。



Pointer-like class

,像指针的类,是指一个类被设计成像指针一样,在可以被当做指针使用的同时,可以具备其他的功能,根据功能可以分为智能指针与迭代器两类,代码如下:

template<class T>
class a_ptr
{
public:
	a_ptr(T* p) :px(p) {}
	T& operator* () const { return *px; }
	T* operator->() const { return px; }
private:
	T* px; 
};


智能指针,用于动态分配内存,可以在类内部处理内存泄露和野指针。关于迭代器,也是一种智能指针,迭代器可以进行增减运算,从而遍历各种容器中的不同元素,详细内容将在另一门课中讲解。



Function-like class

,即仿函数,是一个能执行函数功能的类。在代码中,如果某些功能经经常被使用,如比较大小,为了复用此类代码,可以将其时限为一个公共函数,但是函数所需变量可能是公共的全局变量,导致出现同名冲突,不方便维护。


仿函数优点在于,作为一个简单类,只需维护类的基本成员函数并且重载operator() 运算符 即可实现功能。这样既可以免去对一些公共变量的维护,也可以使重复使用的代码独立出来,以便下次复用,代码如下:

class A_less
{
public:
	A_less(int v) :r(v) { }
	int operator() (int v) const { return v < r ? v : r; }
private:
	int r;
};


随时实例化对象即可调用,可以自主设置比较界限,不同于函数需要设置全局静态变量,只需设置私有属性成员变量。



二、泛型编程


泛型编程,模板是很重要的一部分,本节主要对各类模板的使用以及模板的特性进行分析。



1.Point Directory

  • 类模板
  • 函数模板
  • 成员模板
  • 模板特化
  • 模板偏特化
  • 模板模板参数



2.模板使用


模板根据类型可以分为类模板、函数模板和成员模板三类,对此进行逐一分析。



类模板

,类的结构大体一致,但是类内成员的数据类型未被确定,代码如下:

template<class T>
class a_ptr
{
public:
	a_ptr(T* p) :px(p) {}

	T& operator* () const { return *px; }
 	T* operator->() const { return px; }
private:
	T* px;
};



函数模板

,函数功能和逻辑一致,函数内成员数据类型不确定,代码如下:

template<typename T>
inline const T& amin(const T& a, const T& b)
{
	return a > b ? b : a;
}



成员模板

,类内拷贝构造允许具备可类型转换的数据和继承关系的类进行赋值,可用于类型转换,代码如下:

template<typename T1, typename T2>
class apair
{
public:
	apair() :fir(T1()), sec(T2()) {}
	apair(const& T1 a, const& T2 b) :fir(a), sec(b) {}

	template<typename U1, typename U2>
	apair(const apair<U1, U2>& p) : fir(p.fir), sec(p.sec){}

	T1 fir;
	T2 sec;
};



3.模板特化



模板特化

,针对在特定数据类型下,模板进行专门设计特殊处理,从而提高效率的方法,特化全部类型,称为全特化,代码如下:

// 正常情况可以满足使用
template<typename T>
bool amax(const T& a, const T& b)
{
	return a > b;
}

// 对string类进行特殊处理以满足需求
template<>
bool amax<string>(const string& a, const string& b)
{
	return sizeof(a) > sizeof(b);
}



偏特化

,只对部分类型特化或者缩小类型范围,只对部分类型特化,代码如下:

template<typename T1, typename T2>
class avector {};

template<typename T2>
class avector<bool, T2> {};


偏特化,缩小类型范围,与数据类型不同,指针往往有特殊的处理需求,因此当类型指定为指针时,对其进行特化,代码如下:

template<typename T>
class astack<T*> {};



4.模板模板参数



模板模板参数

,在模板参数中,存在一个模板,代码如下:

template<typename T, template<typename U> typename S>
class ftem{};


分析类模板,第一个参数是类型 T,第二个参数是一个可以指定一个类型 U 的类型 S。使用时,需要将“可以指定一个类型 U 的类型 S”指定给 ftem,代码如下:

template<typename U>
class sem
{
private:
	U t;
};

//实例化对象
ftem<int, sem> f;



三、C++ 11


针对 C++ 11 中三个较为重要的点进行分析,详细内容会在另一门课讲解。



1.Point Directory

  • variadic templates 数量不定的模板参数
  • auto 智能指针
  • ranged-base for 循环



2.variadic templates



数量不定的模板参数

,使用时无需固定参数数量,可以以“包”的形式传入,在内部进行处理,代码如下:

void aprint() { }

template<typename T,typename...Types>
void aprint(const T& f, const Types&...args)
{
	cout << f << endl;
	aprint(args...);
}


递归调用,逐步处理内部数据。



3.auto



auto

,智能指针,编译器可以自动判断指针类型,无需人为指定,代码如下:

auto pi = new int(5);
cout << *pi << endl;

auto ps = new string("helllo");
cout << *ps << endl;

// 输出结果
5
helllo


智能指针在使用时需要注意,指针在初始化时必须赋值,即编译器一开始就需要确定指针类型,不可以在初始化后再进行赋值,代码如下:

// 错误示范
auto pi1;
pi1 = new int(5);



4.ranged-base for



基于范围的for循环

,无需指定范围,可自动开始与结束,常用遍历容器内部元素,代码如下:

vector<int> arr{ 10,20,3,4,6,8,4 };
for (auto elem : arr) { cout << elem << " "; }

// 输出结果
10 20 3 4 6 8 4


默认情况下,元素只可读,如果需要对容器内元素修改,需要修改类型为引用,代码如下:

vector<int> arr{ 10,20,3,4,6,8,4 };
for (auto& elem : arr) { elem += 3; }
for (auto elem : arr) { cout << elem << " "; }

// 输出结果
13 23 6 7 9 11 7



四、对象模型



1.Point Directory

  • 复合、委托和继承关系的构造与析构顺序
  • 虚指针与虚函数表
  • 动态绑定
  • this指针
  • 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_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 //父类析构



3.虚指针与虚函数表



虚指针与虚函数表

,是继承关系中值得深思的一部分,分析类内究竟包含的内容如何,首先从这样一个类开始,代码如下:

class Alkaid
{
public:
	Alkaid(){}
	~Alkaid(){}
};

Alkaid a;
cout << sizeof(a) << endl;

//输出结果
1


在类 Alkaid 内部并无任何数据,但实例化出的对象占用了1字节的内存,这是因为无论类内部是否为空,编译器都必须为其保留1字节的内存,否则无法存储该对象,当类内部存在其他数据是,编译器则会开辟相应大小的内存用来存放,也不再会多保留1字节的内存。


但是当类的析构函数被设为虚函数后,可以发现,输出结果发生了改变,代码如下:

class Alkaid
{
public:
	Alkaid(){}
	virtual ~Alkaid(){}
};

Alkaid a;
cout << sizeof(a) << endl;

//输出结果
8


内存大小为8字节,恰好是x64下一个指针的大小,那么,这个指针为何被定义,指向哪里,又能否被继承呢?继续测试,代码如下:

class Alkaid3529:public Alkaid {};
cout << sizeof(Alkaid3529) << endl;

//输出结果
8


实际上,当一个类内部具有虚函数时,无论是继承或是定义,这个类内部都会被添加一个函数指针,这个函数指针指向一张虚函数表,表内像数组一样依次存放着全部虚函数的指针,当这个类作为基类被继承时,这个虚指针也会被继承给子类,当子类重写虚函数时,会调用虚指针修改虚函数表中存放的函数指针,从而使得函数被重写。

请添加图片描述


子类重写父类虚函数后,修改了继承的虚函数表中的函数指针,也就无法再调用父类的虚函数。


这样的方法常被用于父类的指针指向子类的对象的情况,当多个子类同时继承同一个父类的虚函数,可以对虚函数作出不同的重写,当利用

指向子类的对象的父类的指针

调用该虚函数时就会有不同的结果,代码如下:

class animal
{
public:
	virtual void speak() { cout << "animal is speaking" << endl; }
};

class dog:public animal
{
public:
	void speak() { cout << "dog is speaking" << endl; }
};

class cat:public animal
{
public:
	void speak() { cout << "cat is speaking" << endl; }
};

void speak(animal& a) { a.speak(); }

animal a;
dog d;
cat c;
speak(a);
speak(d);
speak(c);

//输出结果
animal is speaking
dog is speaking
cat is speaking


可以看到,全局speak函数在定义中只接受animal的引用,并调用animal的speak函数,但是当传入animal的子类的引用时,即

父类的引用指向子类的对象

,同样可以正常调用并输出不同的结果,大大增强了代码的可拓展性。



4.动态绑定



dynamic binding

,即动态绑定,也是虚指针和虚函数表底层的汇编代码逻辑。正常来讲,普通的函数调用在程序的编译阶段便会被确定下来,在汇编语言中,代码如下:

00401CF0    call        @LLT+830(A::A)  (00401343)


即普通函数的地址已经被确定下来,无法更改。但是在虚函数被调用时,编译器无法确定也无需确定子类是否重写了继承的虚函数,只需要确定虚函数表中的函数指针即可,在运行阶段根据函数指针调用即可,无需在编译阶段确定,这样也被称为动态绑定。需要注意的是,动态绑定的过程只发生在

父类的引用指向子类的对象

时,因为此时编译器才无法确定究竟调用哪个虚函数,必须在运行阶段根据传入的对象进行确定,代码如下:

00401D78    call        dword ptr [edx]

//转化为c语言
(*(p->vptr)[n])(p)


可以理解为,父类的引用(指针)

p

的虚指针指向的虚函数表

p->vptr

中的第 n 个函数指针

(p->vptr)[n]

解引用后

(*(p->vptr)[n])

得到的函数,将

p

作为this指针进行调用。



5.this指针



this指针

,是所有成员函数的隐含参数,每一个对象都能通过 this 指针来访问自己的地址。但是,友元函数没有 this 指针,因为友元不是类的成员。只有成员函数才有 this 指针。



6.const



const

,当函数被声明为常函数后,函数内便不再对成员变量进行修改,同时,常对象仅能调用常函数。


const 也属于函数签名的一部分,可以被用于函数重载,代码如下:

charT operator[] (int size) const {}
reference operator[] (int size)  { }


在某些容器中,重载中括号可以访问容器内某个数据,也可能只访问某个数据,需要重载。常函数版本则无需考虑COW问题,否则便需要处理COW。




本章总结


本章首先探讨了类的多元设计用途和泛型编程中模板的特化使用,以及c++11的部分新特性,最重要的是对对象模型,尤其是虚指针与虚函数表底层的分析,需要深刻理解并掌握,下一章,我们将深入探讨


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