OOP语义学 第一章 关于对象(Object Lessons)

发布时间 2024-01-11 14:34:32作者: Mesonoxian

第一章 关于对象(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).