Jump Out of C++:面向对象编程探索(一)

这是今年3月29号在IBM俱乐部做的技术分享的文字整理版。


 ppt_cover

这次的系列分享叫做“Jump out of C++:面向对象编程探索”,标题可以分为两部分:一个是关于C++的,一部分是关于面向对象编程的。其实我本来并没有想将太多关于C++的东西,才起名叫“Out of C++”,不过大家一致希望听一些,就把它单独拿出来分享一次。面向对象编程探索的“探索”两个字,其实也有两方面的含义。一个是面向对象编程的发展,另一个就是我自己对它的探索了。

整个讲座大概分为三个部分:第一部分主要介绍C++,以及C++ 11新引进的一些特性。其实C++在语法方面并没有太多想要说的,大家已经都已经很熟悉了;只是有一些不是那么常见、但还比较有用的东西,跟大家分享一下。介绍过了这个之后,我们可以有从两方面继续进行探索:一方面是更加具体:C++的一些运行时机制是如何实现的?另一方面是更加抽象:面向对象编程语言有哪些共同的特性?可以将它们分类吗?这两个方面将分别在第二部分和第三部分中介绍。

Table of Content


  1.  C++是如何诞生的?
  2. 面向过程:C语言兼容性与扩展
  3. 面向对象
  4. 模板与泛型编程
  5. C++ 11 新特性 

1. C++ 是如何诞生的?


  • 具有Simula那样的对程序组织的支持
  • 运行得像BCPL一样快
  • 高度可移植的实现

—— Bjarne Stroustrup

B.Stroustrup在《C++语言的设计和演化》一书中曾提到,上面这三点是他在创造C++时希望这一门语言所拥有的特点。这三点也指导了他整个语言的设计过程。

他是在读博士的时候产生了对C语言进行改进的想法的。他希望编写一个模拟程序,模拟分布式程序的执行。最初使用了Simula进行编写,编写的过程很愉快;可是运行时却不尽人意。Simula的优点在于可以使用类对数据和方法进行管理,同时还支持协程,可以很好地进行并发运算;可是缺点也很明显:编译效率低、运行效率也很低。B.S分析后认为,Simula的垃圾收集机制、运行时类型检查、变量初始化检查是拖慢运行时效率的罪魁祸首之三。

随后他用BCPL语言重新编写了模拟器,运行效率极佳;然而编写的过程却极为痛苦:没有类型检查,运行时的支持也几近为零。

这使他下定决心:如果没有好用的工具,是不会进行下一步工作的。

为什么是C?

C语言有着这些优点:灵活、高效、常用、可移植。灵活高效不必多说,常用是指当时几乎每一台机器上都有较为成熟的C语言编译器和运行环境;可移植更是正中B.S的初衷之一。

然而,C语言也同样有着一些缺点,比如安全性的缺失、以及古怪的声明语法。

比如:

int* v[10];

int (*p)[10];

typedef int DtoI(double);

typedef DtoI *V10[10];

V10* f(char);

我们无法按照正常的习惯从左到右读下去,复杂的声明要借助typedef才能获得一定的可读性。

他曾设想一种新的声明方式,类似如下这种:

v: [10]->int;

p: ->[10];

int f(char)->[10]->(double)->;

可惜的是,貌似是他最开始的时候忘记了;而后又已经无法将其加入语言当中了。

2. 面向过程:C语言兼容性与扩展


隐含的int类型

在C语言中,如果不加类型进行声明,默认即为int。比如

f();

static a;

void f(const T);

由于C++支持了模版机制,对于第三种声明来讲,将对编译器造成极大困惑:T是一种类型,还是类型为const int的变量?

最终废除隐含的int类型终于进入了C++标准。而为了这一结果,花费了整整十年的时间。在最初C++对C语言改革的最大阻力,不是来自B.S,不是来自开发者,不是来自标准委员会,而是C语言的用户。

结构标志与类型名

struct S a;

S a;

在C语言中,只有第一种变量声明是合法的;C++中第二种同样合法。这使得用户自定义类型的地位有所提高。

然而,事情并不是这么简单:

struct

对于C语言来讲,struct S中的符号S与int S中的符号S并不在一个名称空间中;而对于C++来讲,只有一个全局名称空间。这也就意味着,在遇到S时,C++会产生冲突:究竟是类型名还是变量名呢?

最终的选择是当冲突发生时,默认为变量名。原因在于当时有相当多的程序是有着上面左图那样的写法的,而最关键的在于Unix头文件中同样有很多类似的写法。兼容性的需求最终取胜。

全局变量的初始化

在C语言中,全局变量的初始化只能用稍微扩展一点的常量表达式来进行。这使得编译器可以在编译期求值,即不需要在运行时动态初始化。而C++则不同,如果要支持用户自定义类型的全局变量,则动态初始化是不可避免的。也因此支持了内置类型变量的动态初始化。

double p = 10.0 / 3;  //ok in C

double q = sqrt(10.0); //error in C, ok in C++ (Cfront >= 2.0)

class Double;

Double d = sqrt(10.0); //ok in C++

声明语句

支持了任意位置声明,以及在if/for中的声明语句。

其他不兼容的情况

  • 全局名称局部于文件,除非显式引出
  • 默认进行静态类型检查,除非显式抑制
  • 一个类代表一个作用域

在C语言中,void f();表示的是函数f可以接受任意参数。对于调用f(1, 2, 3);来说,C语言中是合法的。同样还有一些隐式类型转换,在C++中都有了更加严格的限制。

同时,在C语言中,结构体对内部的结构体并不起到对作用域的限制:

struct outer{

    struct inner{

        int a;

    };

};

struct inner in = {1};

C++修改了这一特性。

3. 面向对象


说到面向对象,大概就这几个方面:封装、继承、多态、抽象。我们从继承中的多重继承开始说起。

多重继承

多重继承是在Cfront release 2.0引入的。原因大概有这么几点:一个是能够使得设计进一步的推进,而且并不需要太多的扩充即可实现。当然,还有一点“主观因素”:一些人认为实现多重继承比实现参数化类型(即C++模板)更难,甚至有人在自己的书中谈到想要给C++添加多重继承是不可能的。B.S当时也是年少方刚正值气盛,当然要做来给他们看看了。他在之前很早就开始构想了多重继承的实现方式,并且已经想到了一个很好的实现。

对于多重继承,有一些人尚有争论。比如用处不大、增加负担、机制太弱、增加代码复杂程度……

但B.S的态度是:它(实现、运行起来)很便宜,又能解决关键问题,何乐而不为呢?

多重继承带来的典型问题是菱形继承问题。

菱形继承问题

对于多重继承来说,如果父类们有着相同的基类,那么在子类中将有祖父类的多份拷贝。有些时候这并不合理,我们希望子类中只有一个祖父类。C++引入了虚基类来解决这个问题:

虚基类

 事实上,继承也是一种封装:当我们使用父类时,它将子类的信息进行了隐藏。

多态

C++中,多态是利用虚函数实现的。想要表现出多态的效果,需要使用父类的指针或引用。

同时,dynamic_cast动态类型转换也必须对在具有虚函数的类型使用。这是因为他所依赖的运行时类型信息(RunTime Type Information, RTTI)是保存在虚表中的。

抽象类

带有纯虚函数的类叫做抽象类。纯虚函数允许没有函数实体,抽象类不能实例化。

抽象类的作用:

  1. 避免了无意义的对象产生

  2. 抽象类可以用来做界面(接口)

在很多语言中,接口是有单独的关键字来表示的,比如interface之类。C++中,抽象类就可以充当接口的作用:只提供使用方式,而将实现细节完全隐藏。

这样做的好处在于能够使得接口与实现相互分离,用户不需要清楚实现,更不需要显式地依赖具体实现。这也限制了修改后重新编译的范围,降低了耦合度。

4. 模板与泛型编程


 C++的模板支持模板参数的自动推断,以及非类型的参数。比如

template<int b, class T>  
T add(T a) {  
    return a + b;
}
int a = add(1); //a == 3  

同时,C++的模板并没有对类型进行显式限制的功能,例如必须继承自某个类,或是必须有那些方法才可以。C++模板对类型的限制是在使用上,比如 T a; a.print(); 如果类型T没有print方法,则在编译器会报错。

有时候我们需要对模板进行特化。即定义一个特定类型参数的版本,以处理一些特殊的情况。例如

template <class T>  
bool equal(T a, T b) {  
    return a == b;
}

template <>  
bool equal(const char* a, const char*b) {  
    return !strcmp(a, b);
}

同时模板还支持偏特化,即部分特化。例如多个类型参数,只特化其中一部分;或将其不完全特化,例如将普适的类型特化为指针类型。

模板元编程

C++的模板是图灵完备的。也就是说,理论上它可以执行任何计算任务。但由于模板是由编译器在编译期进行计算,受到编译器的限制。

模板元编程与函数式编程语言很像:没有可变变量,所有“函数”都没有副作用。例如下面是在编译期计算斐波那契数列:

template <int n>  
struct Fib {  
    enum {
        Result = Fib<n - 1>::Result + Fib<n - 2>::Result
    };
};

template <>  
struct Fib<1> { enum { Result = 1 }; };

template <>  
struct Fib<0> { enum { Result = 0 }; };

int main() {  
    int i = Fib<5>::Result;
    std::cout << i << std::endl;
    return 0;
}

模板在最初设计的时候只是为了参数化容器而设计的,最终能够用来进行元编程是一种“意外”,因此从语法上来说也并不是很舒服。

5. C++ 11 带来了什么?


  • 更优雅的初始化
  • 更优雅的委托
  • 更优雅的RAII
  • 更高的效率
  • 更强大的模板

更优雅的初始化

更优雅的初始化方式包括初始化列表、新增的类成员初始化方式、代理构造函数以及统一的初始化语法等。例如:

class IntArray {  
public:  
    IntArray(int s) : size(s) { ptr = new int[s]; } 
    IntArray() : IntArray(50){}     
    IntArray(std::initializer_list<int> list) : 
    IntArray(list.size()) { //... } 
private:  
    unsigned size = 0; 
    int* ptr = nullptr; 
}; 
IntArray intArray = { 1, 2, 3 };  

 上例中,使用std::initializer_list将可以接受类似数组方式定义的初始化成员值;在第三个接受初始化列表的构造函数中,其调用了第一个只接受整型长度的构造函数;而在类的内部,则可以直接使用赋值语法对成员进行初始化。如果在构造函数的成员初始化列表(冒号之后的列表,不是initializer_list)中没有对相应成员进行初始化,则使用在类中声明的值用作初始化。

同时,C++ 11还统一了初始化的语法。任何类型均可以使用大括号进行成员的初始化,例如:

class Test {  
public:  
    Test(int a, int b){} 
    virtual ~Test(){} 
}; 

int main() {  
    Test t{1, 2}; 
    Test t2 = {1, 2}; 
    int t3 = {1}; 
    int t4{1}; 
}

 上述代码中,四条初始化语句都是正确的。当存在接受initializer_list作为参数的构造函数时,大括号将会被解释为initializer_list(例如之前的IntArray)。

更优雅的委托

什么是委托?

class A {  
public:  
    void p(); 
}; 

class B {  
    A a; 
public:  
    void p(){ a.p(); } 
};

 当B有一件事情,转交给A去做的情况,就叫做委托。事实上,委托也可以用来实现继承;而在C++的早期,B.S也曾经设想过使用委托来实现继承,但最终放弃了这一想法。对于近几年新出炉的Go语言来说,自动委托是用来实现类似于继承思想的一种方式。

对于稍微复杂一点的委托,例如B希望A能够执行一些自己设定的任务(比如回调),则稍稍有些麻烦。在C语言中,函数指针经常用来承担这种任务:

typedef void (*FUNC)();  
class A {  
public:  
    void q(FUNC func) (func();) 
}; 
void f() {/*...*/}  
int main() {  
    A a; 
    A.q(f); 
    return 0; 
}

 然而,上述代码对于类的成员函数则不适用。对于以下三个f函数来说,他们的类型是不同的:

class B {void f();};  
// void B::(*)(); 

class C {void f();};  
// void C::(*)(); 

void f();  
// void (*)();

底层的原因则在于B和C有着一个不同类型的隐含参数:this指针。因此,这三个f函数,只有最后一个能够传给A.q作为委托的任务。

一个典型面向对象解决方案可以统一B和C参数不同的问题。使用一个Listener纯虚类作为接口,类A接受一个Listener作为参数,而B和C均继承自Listener:

class Listener {  
public:  
    virtual void f() = 0; 
}; 

class A {  
public:  
    void q(Listener *listener){listener->f();} 
};

class B : public Listener {  
public:  
    void f(){} 
}; 

class C : public Listener {  
public:  
    void f(){} 
}; 

int main() {  
    A a; 
    B b; 
    C c; 
    a.q(&b);
    a.q(&c); 
    return 0; 
}

 这种方式在Java中非常常见。然而,这种方法使用了面向对象的方式仅仅解决了面向对象那一部分的问题——A仍然无法接受不属于任何类的f()。

 C++ 11新引入了多态函数对象包装器std::function,可以很好地解决这个问题。

class A {  
public:  
    void q(function<void> f){f();} 
}; 

class B {  
public:  
    void Callback() {} 
}; 

void normalFunc(){} 

int main() {  
    A a; 
    B b; 
    a.q(bind(&B::Callback, &b)); 
    a.q(normalFunc); 
    return 0; 
}

实际上,藉由std::function,C++ 11还提供了闭包的功能,并引入了匿名函数来简化语法:

void someFunc(function<void> q){q();}  
int main() {  
    int a = 2, b = 3; 
    auto f = [=]() -> void { //[&a, &b] 
        cout << a + b << endl;
    };
    someFunc(f);
    return 0;
}; 

 更优雅的RAII

RAII(资源获取即初始化)是C++中对于资源管理比较重要的一个概念,最初由B.S在他的The C++ Programming Language中提到。在C语言中,一个常见的资源管理策略是:谁申请,谁释放。“谁”可以指函数,也可以指模块。

谁申请,谁释放

举例来说,例如一个负责字符串编码转换的函数:

bool Charset1ToCharset2( const char* input, unsigned input_size, char* output, unsigned output_size ) {  
    //Convert charset return true; 
}

显然,根据谁申请、谁释放的原则,这个函数不应该在函数内部申请内存,而应当由外部调用者申请好资源后传入。可是调用者如何知道最终转换后的字符串需要多大的空间呢?

因此,将函数修改如下:

bool Charset1ToCharset2( const char* input, unsigned input_size, char* output, unsigned output_size, unsigned* output_needed ) {  
    *output_needed = calc_output_size(); 
    if (output_size < *output_needed)
        return false;

    //Convert charset
    return true;
}

此时在func1中需要调用两次Charset1ToCharset2,第一次用以查询需要多少空间;第二次真正接收字符串编码转换的结果。这种方式在Windows API中也非常常见。

然而,这种方式在有很多分支、很多资源需要申请时,资源释放是一件相当繁琐的事情。尤其在C++支持了异常之后,很有可能会出现资源没有释放,函数就返回的情况。

RAII就是为了解决这一问题而产生的管理方式。它通过对象的生命周期来管理资源,当对象的生命周期结束时,自动释放所持有的资源:

class FileRAII {  
public:  
    FileRAII(const char* n, const char* m) 
    { /*fp = fopen(n, m); */} 

    FileRAII(FILE* p) { fp = p; }

    ~FileRAII() { fclose(fp); } 

    operator FILE*() { return fp; } 
private:  
    FILE* fp; 
}; 

bool func() {  
    FileRAII fp(fopen("1.txt", "r")); 
    if (fp == NULL) 
        return false; 
    //Use fp... 
    return true; 
}

不论func正常退出,或是在使用fp时触发了C++异常而导致异常退出,fp都会被析构,即fclose(fp)都会被执行。

在C++ 11 之前,实现一个通用的RAII类是比较复杂的。然而,当匿名函数和闭包被引入了之后,它变得十分简单:

class ScopeGuard {  
public:  
    ScopeGuard(function<void> on_exit) : _on_exit(on_exit){} 
    ~ScopeGuard() { _on_exit(); } 
    ScopeGuard(const ScopeGuard&) = delete; 
    ScopeGuard & operator=(const ScopeGuard&) = delete; private: 
    function<void> _on_exit; 
}; 

void func5() {  
    FILE* fp = fopen("1.txt", "r"); 
    ScopeGuard s([=] {fclose(fp); }); 
}

 垃圾收集:为什么不支持?

  1.  不适合低层次的工作
  2. 效率
  3. C语言兼容性
  4. 对象布局、创建的限制
  5. 不确定状态

  6. 很多GC算法在运行时要求暂时中断程序的运行。对于很多程序来讲,这可能并不是太大的问题;但是,这将使得C++不再适用于低层次的工作。在当时,C++已经被广泛地用于各种实时系统、嵌入式系统中,他们对这个问题实在是太过敏感。

  7. 时空开销。对于今天的硬件发展来说,这个问题小了很多,但弊端同上一点。如果真的要支持GC,提供一个开关也是比较合理的考虑。

  8. C语言支持一些底层的功能,比如指针运算、联合、强制类型转换等,还有不加检查的数组、不加检查的函数参数等等对于GC算法的设计非常不利的功能。比如,当用户申请了一块堆内存时,如何判断用户已经不再需要这块内存了呢?由于C语言支持指针运算,用户可以轻易地将指针算走之后再算回来。

  9. 垃圾收集同样将导致与其他语言的接口变得复杂。

  10. 用户难以知道某一块内存是否被释放了,它的状态是不确定的。

更高的效率

C++ 11中引入了右值引用、move语义和常量表达式来实现效率的提升。

“值”是指无法被进一步求值的表达式。对于“值”来说,通常我们分为左值和右值。一开始,左值被定义为“赋值号左面的值”,右值相反。可是,这种定义明显不再适用于C++。C++ 11中,明确定义了3种值类型:

左值:可以取地址的值

临终值:生命期即将结束,但其值尚未被取走。例如返回右值引用的函数的返回值,或是强制转换后的右值。

纯右值:不具有标志,可以移动。

广义的左值是指左值+临终值,他们可以表现多态;广义的右值指纯右值+临终值,他们不能被取地址。

在C++ 11之前,“引用”可以引用左值,“常量引用”可以引用左值+右值,然而缺乏一种方式只能引用右值。例如,在C++ 11之前,当一个函数返回一个对象时,将会有临时对象被创建。此时临时对象的构造将会增加程序运行的成本。

class IntArray  
{
public:  
    IntArray(int s) : size(s)
    {
        ptr = new int[s];
        cout << "constructor" << endl;
    }
    IntArray(const IntArray& a)
    {
        delete[] ptr;
        size = a.size();
        ptr = new int[size];
        memcpy(ptr, a.ptr, sizeof(int) * size);
        cout << "copy constructor" << endl;
    }

    IntArray& operator=(const IntArray& a)
    {
        //......
        cout << "assgin operator" << endl;
        return *this;
    }
private:  
    unsigned size = 0;
    int* ptr = nullptr;
};

在VS中的测试结果是:未优化版本将有一次构造、一次拷贝和一次赋值;优化版本将有一次构造和一次赋值。Move语义在这里就派上了用场,它与Copy不同之处在于:

Move语义Copy语义 

当Object1对象是右值时,通过Move方式得到资源将会省去一次拷贝的过程。反正Object1也即将被释放,倒不如把资源送给Object2。

class IntArray  
{
public:  
    IntArray(const IntArray&& a)
    {
        delete[] ptr;
        size = a.size();
        ptr = a.ptr;
        a.ptr = nullptr;
        cout << "move constructor" << endl;
    }

    IntArray& operator=(const IntArray&& a)
    {
        //......
        cout << "move assign operator" << endl;
        return *this;
    }
};

constexpr关键字代表了新的常量表达式,它将在编译期被求值。例如:

constexpr int GetValue() {  
    return 5;
} 

int main() {  
    int a[GetValue() + 1];
    return 0; 
}

`
 在constexpr出现之前,这是不能编译通过的。带有constexpr修饰的语句将会在编译期被求值,如果无法求值的话将会出现编译错误。

常量表达式可以替代部分宏函数的功能,比如

enum Flags { good=0, fail=1, bad=2, eof=4 }; 

constexpr int operator|(Flags f1, Flags f2) {  
    return Flags(int(f1)|int(f2)); 
} 

void f(Flags x) {  
    switch (x) { 
    case bad: break; 
    case eof: break; 
    case bad|eof: break; 
    default: break; 
    } 
}

还有一些情况,是曾经无法支持的。例如,对于一个字符串常量来说,它其中的每一个字符也应当可以在编译器求值。但在constexpr出现之前,我们必须在运行时计算地址,然后取值;现在则不同:

constexpr char* str = "Hello World";  
int main() {  
    constexpr char a = str[1]; 
    cout << a << endl;
    return 0;
}

a将在编译期被取值。同样,我们也可以在编译期求值很多东西,它可以替代一部分模板的功能:

constexpr int exp(int a, int b) {  
    return (b == 0) ? 1 : a * exp(a, b - 1); 
} 
constexpr int a = exp(2, 10);  

 一个函数能否在编译期被求值是有要求的,例如不能有循环语句等。

更强大的模板

C++ 11 加强了模板的功能:模板不定参数、完美转发、编译期Assert、模板别名、返回类型后置(用来进行类型推导)等。

其他常见C++ 11特性

  • Range-based for
  • 强类型枚举
  • 新字符串字面值
  • thread
  • long long int
  • STL:tuple/unordered_map/regex/….