OOP语义学 第一章 关于对象(Object Lessons)
第一章 关于对象(Object Lessons)
struct与class
在C语言中,"数据"与"处理数据的操作(函数)"是分开声明的.语言本身没有支持"数据和函数"之间的关联性.我们把这种程序方法称为"程序性的(procedural)."
举个例子:
如果我们声明一个struct Point3d,像这样:
typedef struct point3d{
float x,y,z;
}Point3d;
想要打印它,可能就需要定义一个下面这样的函数:
void Point3d_print(const Point3d* pd)
{
printf("($f,%f,%f)",pd->x,pd->y,pd->z);
}
而在C++中,Point3d有可能采取独立的"抽象数据类型(abstract data type,ADT)"来实现.
class Point3d{
public:
Point3d(float x=0.0,float y=0.0,float z=0.0)
:m_x(x),m_y(y),m_z(z){}
float x(){return m_x;}
float y(){return m_y;}
float z(){return m_z;}
//...etc...
private:
float m_x,m_y,m_z;
}
或者是一个双层或者三层的class层次结构完成:
class Point{
public:
Point(float x=0.0):m_x(x){}
float x(){return m_x;}
//...etc..
private:
float m_x;
}
class Point2d:public Point{
public:
Point2d(float x=0.0,float y=0.0):Point(x),m_y(y){}
float y(){return m_y;}
///...etc...
private:
float m_y;
}
class Point3d:public Point2d{
public:
Point3d(float x=0.0,float y=0.0,float z=0.0)
:Point2d(x,y),m_z(z){}
float z(){return m_z;}
//...etc...
private:
float m_z;
}
总而言之,在面向对象程序设计的角度来看.C风格的struct是面向过程的,C++风格的class是面向过程的.
C++对象模式
在C++中,有两种数据成员:静态与非静态;有三种方法:静态方法,非静态方法与虚方法.
- 简单对象模型(A Simple Object Model):在该模型中,一个object是一系列的slots,每个slot指向一个members.
即slot与member之间存在1..1的关系.
在简单对象模型下,members本身不放在object中,只有指向member的指针在object里.这样可以避免member类型不同导致存储空间不同带来的问题.
这个模型本身并没有被应用于实际产品,但是其关于slot的观念,被应用在C++"指向成员的指针"(pointer-to-member)的观念中.
$$object(slots)\to \text{data member+function member}$$
- 表格驱动对象模型(A Table-driven Object Model):该模型中,将member分为data member与function member两种,分别存放在Member Data Table(实际数据)与Fuction Member Table(函数地址)两个表中.其中Function Member Table本身是一系列的slots,每一个slot指出一个member function.
虽然这个模型也没有实际应用,但是member function table这个观念却成为支持virtual functions的有效方案.
$$object \to\text{Member Data Table[Data Members]+}\\text{(Function Member Table(slots)} \to\text{Function Members})$$
- C++对象模型(The C++ Object Model):该模型从简单对象模型派生而来.为C++所采取的对象模型.
该模型为每个class object都安插了一个指针,指向相关的virtual table.这个指针被称为vptr.
每一个class产生出一堆指向virtual functions的指针,放在表格中,称为virtual table(vtbl).
$$object(vptr)\to vtbl\text{(Virtual Table)}\to \text{(type info+virtual function member)}$$
继承模型
- old C++模型:在没有虚拟继承等相关设计的情况,C++的继承模型完全来自于C语言.通过derived class内包含base class data member实现.
即可以想象为类似于下面结构的形式:
class iostream{
public:
class ostream out;
class istream in;
//...etc...
}
- base table模型:在虚拟继承的情况下,base class不管在继承串链中被派生(derived)多少次,永远只会存在一个实例(称为subobjcet).在"简单对象模型"中,每一个base class可以被derived class object内的一个slot指出,该slot内含base class subobject的地址.
即可以想象为类似于下面结构的形式:
class iostream:public istream,public: ostream{
//...etc...
}
当base class table被产生出来时,table内每一个slot内含一个相关的base class地址.每个class object内含一个bptr,它会被初始化指向base class table.
通过这样,无需改变class objects本身便可以修改base class table的内容.
$$\text{derived class}\to bptr(slots)\to\text{base classes}$$
- virtual base classes模型:其原始模型是在class object中为每一个有关联的virtual base class加上一个指针.即用vpbl来代替bptr的位置.
$$\text{derived class}\to vtbl(slots)\to\text{base classes}$$
struct与class的语义区分
通常而言,struct用于仅需要处理数据集合的情景,而class用于其他的任何情况.
struct与class的一致性
虽然在C++中struct被视为class的一种默认权限不同的版本,但是在除去应当使用它的情景,任何情况都不应该使用它.
这是因为关键词struct本身并不一定要象征其随后声明的任何东西,下面的两种情况在C++中完全相等:
struct keyword{
//something...
};
class keyword{
public:
//the same things
};
下面用一个小实验证明二者的一致性:
struct Class {
int x;
};
class Class {
public:
int x;
};
事实上,这段代码无法通过编译,因为struct Class和clas Class实际上是同一个东西,对struct Class发生了重定义.
struct与class的差异性
然而,由于C++作为C的父集,C++中对struct的修改会造成一些意想不到的问题.
下面是个小小的实验.
在C语言中,将单一元素的数组放在一个struct的尾端可以使得每个struct objects拥有可变大小的数组:
struct mumble{
//stuff...
char pc[1];
};
char str[15] = "hello world";
struct mumble* pmumb1 = (struct mumble*)malloc(sizeof(struct mumble)+strlen(str)+1);
strcpy(pmumb1->pc,str);
printf("%s", pmumb1->pc);
然而如果使用class来处理,则遇到了困难.
C++中凡处于同一个access section的数据,必定保证以其声明顺序出现在内存布局当中.然而被放置在多个access sections中的各笔数据,排列顺序就不一定了.
C++中class内数据的存储顺序,与其声明顺序是密切相关的,下面以一个实验说明:
class test1 {
public:
int a = 1;
private:
int b = 2;
protected:
int c = 3;
};
class test2 {
private:
int c = 1;
protected:
int b = 2;
public:
int a = 3;
};
int main()
{
test1 temp1;
int* p = &(temp1.a);
std::cout << *p;//1
std::cout << *(++p);//2
std::cout << *(++p);//3
test2 temp2;
p = &(temp2.a);
std::cout << *p;//3
std::cout << *(--p);//2
std::cout << *(--p);//1
}
通过这个例子,让我们回到上面关于struct的问题.不难发现,在C++中,对struct稍作调整,使其变为形如下面结构:
struct mumble {
public:
//stuff...
char pc[1];
private:
//stuff...
char temp[10] = "hello";
};
则在对pc进行读写时,不可避免地会对private section内的内容进行覆写.这是危险而又令人厌烦的.
特别的,为了进一步说明这种关键词上的差异,举一个例子:
template<class T>void func1();//合法的
template<struct T>void func2();//非法的
上面的声明合法,而下面的声明却因为使用了struct关键字而非法.
C++程序设计范式
C++程序设计直接支持三种范式(programming paradigms).
- 程序模型(procedural model):就像C一样,C++也支持它.这是一般过程式语言的范式.该范式基于逻辑.
char boy[]="John";
char *p_person;
//...
p_person = new char[strlen(boy)+1];
strcpy(p_son,boy);
//...
if(!strcmp(p_son.boy))
take_to_disneyland(boy);
- 抽象数据类型模型(abstract data type model,ADT):该模型的"抽象"是和一组表达式(public接口)一起提供的.该范式基于重载.
string girl = "Anna";
string daughter;
//...
string::operator=();
daughter = girl;
//...
string::operator==();
if(girl==daughter)
take_to_disneyland(girl);
- 面向对象模型(object-oriented model,OO):该模型中有一些彼此相关的类型,通过一个抽象的base class(用以提供共同接口)被封装起来.该范式基于接口.
void check_in(Library_materials* pmat)
{
if(pmat->late())
pmat->fine();
pmat->check_in();
if(Lender* plend=pmat->reserved())
pmat->notify(plend);
}
多态模型(polymorphism model)
在多态有关问题中,常常被讨论的一个问题是裁切(sliced)
让我们通过下面一个例子来了解:
class Library_materials {
public:
Library_materials(int id, std::string name) :
m_id(id), ms_name(name) {}
int m_id;
std::string ms_name;
};
class Book :public Library_materials {
public:
Book(int id, std::string name, std::string isbn) :
Library_materials(id, name), ms_ISBN(isbn) {}
std::string ms_ISBN;
};
Book book{ 1,"Algorithm","978-7-121-14952-8" };
Library_materials material = book;
//std::cout<<material.ms_ISBN;//无法找到,ms_ISBN被裁切了
这里不难发现,在从derived class到base class的复制过程中,成员数据ms_ISBN被裁切了.这是因为成员ms_ISBN存在于derived class而不存在于base class之中.
然而,通过pointer或reference的间接处理,多态的威力就体现出来.
下面我们再通过一个例子来了解:
class Library_materials {
public:
Library_materials(int id, std::string name) :
m_id(id), ms_name(name) {}
virtual void check_in() { std::cout << "LM checked\n"; }
int m_id;
std::string ms_name;
};
class Book :public Library_materials {
public:
Book(int id, std::string name, std::string isbn) :
Library_materials(id, name), ms_ISBN(isbn) {}
void check_in() { std::cout << "Book checked\n"; }
std::string ms_ISBN;
};
Book book{ 1,"Algorithm","978-7-121-14952-8" };
Library_materials material1 = book;
Library_materials& material2 = book;
Library_materials* material3 = &book;
material1.check_in();//Library_materials::check_in()
material2.check_in();//Book::check_in()
material3->check_in();//Book::check_in()
得到的结果为:
LM checked
Book checked
Book checked
通过比较,发现通过间接处理的方式处理derived class object可以很好地体现base class object的性质而避免裁切.
在ADT范式中,程序员处理的是一个拥有固定而单一的实例,在编译期就已经完全定义好了.而通过间接方式实现多态无法确定类型,只能说明其为相关类型的子类型(subtype).
//描述objects:不确定类型
Library_materials *px = retrieve_some_material();
Library_materials &rx = *px;
//描述已知对象:不会有意外的结果产生
Library_materials dx = *px;
一般而言,OO范式中的"多态",指的是"对于object的多态操作",下面通过一个例子说明:
int *pi;//没有多态,操作对象不是class object
void *pvi;//没有语言所支持的多态,原因同上
x *px;//多态,class x视为base class
C++通过以下方法支持多态:
- 将derived class指针转化为指向其public base type的指针.
- 经由virtual function机制
- 经由dynamic_cast和typeid运算符
我们回想virtual base classes模型中vtbl机制带来的效果,就可以更好地理解上面的内容.
class object内存模式
思考一个问题:需要多少内存才能够表现一个class object?
一般而言,有:
- 其nonstatic data members的总和大小.
- 加上任何由于alignment的需求而填补(padding)的空间.
(位置不确定,可能在members之间,可能在集合体边界) - 加上为了支持virtual而内部产生的额外负担(overhead).
举例,假设存在一个class object如下所示:
class Book{
int len;
char* name;
char flag;
virtual void func(){};//若没有虚函数,virtual机制不会被启用,没有vptr
}
对其进行分析,有:
- nonstatic data members的总和:一个int型(4-bytes),一个char*型(4-bytes),一个char型(1-bytes),4+4+1=9
- 起始地址loc所占用的空间:一个int型(4-bytes),4
- 为支持virtual机制,vptr所占用的空间:一个指针(4-bytes),4
- 为了alignment而填补的空间(通常4-bytes为单位),4-1=3
总计4+9+4+3=20bytes,这就是初略估计下一个该class object所占的空间.
而class object指针的大小都是一致的,占用一word的空间来存储一个机器地址.不同机器上的word为可变大小.
下面所有的指针占用的空间都是一致的:
ZooAnimal* px;
int* pi;
std::array<std::string>* pta;
通常而言,指向class object的指针通常保存的是在object内存头部的loc的地址.
因而,转换(cast)其实是一种编译器指令.大部分情况下它并不改变一个指针所含的真正地址,它只影响"被指出之内存的大小和其内容"的解释方式.
现在,让我们来到加入多态后class object的内存情况.
class Animal{
public:
int len;
char* name;
char flag;
virtual void eat();
virtual void move();
};
class Mouse:public Animal{
public:
char type;
void hide();
};
在前面的内容中,我们已经知道了:在一个class object中,其数据成员存储顺序按照其声明顺序排列.
事实上,在derived class object中,其所继承的base class作为subobject存储在object的最前面,而其他数据成员按照声明顺序紧随其后.下面我们通过一个实验来说明:
class A {
public:
int a = 1;
int b = 2;
};
class B :public A{
public:
int c = 3;
int d = 4;
};
B temp;
int* p = &(temp.c);
std::cout << *(p - 2);//1
std::cout << *(p - 1);//2
std::cout << *p;//3
std::cout << *(p + 1);//4
而在多重继承中,subobject的存储顺序是按照继承顺序实现的.下面我们再来做一个实验说明:
class A {
public:
int a = 1;
int b = 2;
};
class B {
public:
int c = 3;
int d = 4;
};
class C :public B,public A {
public:
int e = 5;
int f = 6;
};
C temp;
int* p = &(temp.e);
for (int i = -4; i < 2; i++)
std::cout << *(p + i);
//341256
那么,what about指针?在前面的内容里,我们已经讨论过了:通过间接方式访问derived object的base object指针可以调用derived object内的function members.
于是,让我们来进行猜想:是否是不同类型指针内地址组织形式不同造成了loc解释的不同?(前面已知存储的地址长度相同)
通过一个实验来说明:
class A {
public:
int a = 1;
int b = 2;
};
class B : public A {
public:
int c = 3;
int d = 4;
};
B temp;
A* pa = &temp;
B* pb = &temp;
std::cout << pa << std::endl;//00000068D86FFA18
std::cout << pb << std::endl;//00000068D86FFA18
我们发现,其实二者所保存的地址完全相同.这告诉我们:继承情况下的指针的类型的访问实际上与保存的地址无关.
在上面的实验中,pb涵盖的地址包含整个B object,而pa所涵盖的地址只包含B object中的A subobject.
为了通过base object的指针访问subobject外出现的object members,需要通过virtual机制.
可以通过static_cast与运行期的dynamic_cast方式来实现.例如:
(static_cast<B*>(pa)).c;
(dynamic_cast<B*>(pa)).c;
事实上,指针所储存的仅仅为目标object的第一个byte,而范围的length由指针的数据类型显式说明.
由此我们猜想:derived class object的内存布局如下:
$$object=loc+subobject+members+vptr+padding$$
$$subobject=loc+members+vptr+padding$$
下面我们通过一个实验说明:
class A {
public:
int a = 1;
int b = 2;
virtual void func1() {};
};
class B : public A {
public:
int c = 3;
int d = 4;
};
A arr[2];
B* pb = static_cast<B*>(&(arr[0]));
std::cout << &(arr[0].a) << std::endl;
std::cout << &(arr[0].b) << std::endl;
std::cout << &(arr[1].a) << std::endl;
std::cout << &(arr[1].b) << std::endl;
std::cout << std::endl;
std::cout << &(pb->a) << std::endl;
std::cout << &(pb->b) << std::endl;
std::cout << &(pb->c) << std::endl;
std::cout << &(pb->d) << std::endl;
其运行结果为:
000000961DEFF5D0
000000961DEFF5D4
000000961DEFF5E0
000000961DEFF5E4
000000961DEFF5D0
000000961DEFF5D4
000000961DEFF5D8
000000961DEFF5DC
该实验利用了数组内存连续的性质,通过指针来对地址进行操作.对结果进行对比,不难发现vptr的存在.
最后,让我们来认识一种具体ADT程序风格:object-based(OB).