现在的位置: 首页 > 综合 > 正文

【Deep C (and C++)】深入理解C/C++(3)

2013年12月24日 ⁄ 综合 ⁄ 共 6440字 ⁄ 字号 评论关闭

译自Deep C (and C++) by Olve Maudal and Jon Jagger,本身半桶水不到,如果哪位网友发现有错,留言指出吧:)

 

第二位候选者表现不错,那么,相比大多数程序员,他还有什么潜力没有被挖掘呢?

可以从以下几个角度去考察:

有关平台的问题—32位与64位的编程经验;

内存对齐;

CPU以及内存优化;

C语言的精髓;

 

接下来,主要分享一下以下相关内容:

内存模型;

优化;

C语言之精髓;

 

 

内存模型:

静态存储区(static storage):如果一个对象的标识符被声明为具有内部链接或是外部链接,或是存储类型说明符是static,那么这个对象具有静态生存期。这个对象的生命周期是整个程序的运行周期。

PS:内部链接,也就是编译单元内可见,是需要使用static来修饰的,连接程序不可见;外部链接,是指别的编译单元可见,也就是链接程序可见。我这里还不太清楚为什么需要三种情况来说明。

int* immortal(void)
{
    static int storage = 42;
    return &storage;
}

 

自动存储区(automatic storage):如果一个对象没有被指明是内部链接还是外部链接,并且也没有static修饰,那么,这个对象具有自动生存期,也称之为本地生存期。一般使用auto说明符来修饰,只在块内的变量声明中允许使用,这样是默认的情况,因此,很少看到auto说明符。简单地说,自动存储区的变量,在一对{}之间有效。

int* zombie(void)
{
    auto int storage = 42;
    return &storage;
}

分配的存储区域(allocated storage):调用calloc函数,malloc函数,realloc函数分配的内存,称之为分配的存储区域。他们的作用域(生命周期会是更好的术语吗?)在分配和释放之间。

 

int* finite(void)
{
    int* ptr = malloc(sizeof(int*));
    *ptr = 42;
    return ptr;
}

 

优化相关:

一般来说,编译的时候,你都应该打开优化选项。强制编译器更努力的去发现更多的潜在的问题。

上面,同样地代码,打开优化选项的编译器得到了警告信息:a 没有初始化。

  

C语言的精髓:

C语言的精髓体现在很多方面,但其本质在于一种社区情感(communitysentiment),这种社区情感建立在C语言的基本原则之上。

C语言原理简介:

1、  相信程序员;

2、  保持语言简单精炼;

3、  对每一种操作,仅提供一种方法;(译者注:?)

4、  尽可能的快,但不保证兼容性;

5、  保持概念上的简单;

6、  不阻止程序员做他们需要做的事。

  

 

现在来考察一下我们的候选者关于C++的知识:)

 

你:1到10分,你觉得你对C++的理解可以打几分?

第一个候选者:我觉得我可以打8到9分。

第二个候选者:4分,最多也就5分了。我还需要多加学习C++。

这时,C++之父Bjarne Stroustrup在远方传来声音:我觉得我可以打7分。(OH,MY GOD!!)

 

那么,下面的代码段,会输出什么?

#include <iostream>

struct X
{
    int a;
    char b;
    int c;
};

int main(void)
{
    std::cout << sizeof(X) << std::endl;
}

 

第二个候选者:这个结构体是一个朴素的结构体(POD:plain old data),C++标准保证在使用POD的时候,和C语言没有任何区别。因此,在你的机器上(64位机器,运行在32位兼容模式下),我觉得会输出12.

顺便说一下,使用func(void)而不是用func()显得有点诡异,因为C++中,void是默认情况,这个相对于C语言的默认是任意多的参数,是不一样的。这个规则同样适用于main函数。当然,这不会带来什么伤害。但这样的代码,看起来就像是顽固的C程序员在痛苦的学习C++的时候所写的。下面的代码,看起来更像C++:

#include <iostream>

struct X
{
    int a;
    char b;
    int c;
};

int main()
{
    std::cout << sizeof(X) << std::endl;
}

 

第一个候选者:这个程序会打印12.

你:好。如果我添加一个成员函数,会怎么样?比如:

#include <iostream>

struct X
{
    int a;
    char b;
    int c;

    void set_value(int v) { a = v; }
};

int main()
{
    std::cout << sizeof(X) << std::endl;
}

第一个候选者:啊?C++中可以这样做吗?我觉得你应该使用类(class)。

你:C++中,class和struct有什么区别?

候选者:在一个class中,你可以有成员函数,但是我不认为在struct中可以拥有成员函数。莫非可以?难道是默认的访问权限不同?(Is it the default visibility that is different?)

不管怎样,现在程序会输出16.因为,会有一个指针指向这个成员函数。

你:真的?如果我多增加两个函数呢?比如:

#include <iostream>

struct X
{
    int a;
    char b;
    int c;

    void set_value(int v) { a = v; }
    int get_value() { return a; }
    void increase_value() { a++; }
};

int main()
{
    std::cout << sizeof(X) << std::endl;
}

第一个候选者:我觉得对打印24,多了两个指针?

你:在我的机器上,打印的值比24小。

候选者:啊!对了,当然,这个struct有一个函数指针的表,因此他仅仅需要一个指向这个表的指针!我确实对此有一个很深的理解,我差点忘记了,呵呵。

你:事实上,在我的机器上,这段代码输出了12.

候选者心里犯嘀咕:哦?可能是某些诡异的优化措施在捣鬼,可能是因为这些函数永远不会被调用。

 

你对第二个候选者说:你怎么想的?

第二个候选者:在你的机器上?我觉得还是12?

你:好,为什么?

候选者:因为以这种方式来增加成员函数,不会增加struct的所占内存的大小。对象对他的函数一无所知,反过来,是函数知道他具体属于哪一个对象。如果你把这写成C语言的形式,就会变得明朗起来了。

你:你是指这样的?

struct X
{
    int a;
    char b;
    int c;
};

void set_value(struct X* this, int v) { this->a = v; }
int get_value(struct X* this) { return this->a; }
void increase_value(struct X* this) { this->a++; }

第二个候选者:恩。就想这样的。现在很明显很看出,类似这样的函数是不会增加类型和对象的内存大小的。

 

你:那么现在呢?

#include <iostream>

struct X
{
    int a;
    char b;
    int c;

    virtual void set_value(int v) { a = v; }
    int get_value() { return a; }
    void increase_value() { a++; }
};

int main()
{
    std::cout << sizeof(X) << std::endl;
}

//注意改变:第一个成员函数变成了虚函数。

第二个候选者:类型所占用的内存大小很有可能会增加。C++标准没有详细说明虚类(virtual class)和重载(overriding)具体如何实现。但是一般都是维护一个虚函数表,因此你需要一个指针指向这个虚函数表。所以,这种情况下会增加8字节。这个程序是输出20吗?

你:我运行这段程序的时候,得到了24.

候选者:别担心。极有可能是某些额外的填充,以便对齐指针类型(之前说的内存对齐问题)。

你:不错。再改一下代码。

#include <iostream>

struct X
{
    int a;
    char b;
    int c;

    virtual void set_value(int v) { a = v; }
    virtual int get_value() { return a; }
    virtual void increase_value() { a++; }
};

int main()
{
    std::cout << sizeof(X) << std::endl;
}

现在会发生什么?

第二个候选者:依旧打印24.每一个类,只有一个虚函数表指针的。

你:恩。什么是虚函数表?

候选者:在C++中,一般使用虚函数表技术来支持多态性。它基本上就是函数调用的跳转表(jump table),依靠虚函数表,在继承体系中,你可以实现函数的重载。

 

让我们来看看另一段代码:

#include "B.hpp"

class A {
    public:
      A(int sz) { sz_ = sz; v = new B[sz_]; }
      ~A() { delete v; }
      //...
    private:
      //...
      B* v;
      int sz_;
};

看看这段代码。假设我是一名资深的C++程序员,现在要加入你的团队。我向你提交了这么个代码段。请从学术的层面,尽可能详细轻柔的给我讲解这段代码可能存在的陷阱,尽可能的跟我说说一些C++的处理事情的方式。

第一个候选者:这是一段比较差的代码。这是你的代码?首先,不要使用两个空格来表示缩进。还有class A后面的大括号要另起一行。sz_?我从来没见过如此命名的。你应该参照GoF标准_sz或且微软标准m_sz来命名。(GoF标准?)

你:还有呢?

候选者:恩?你是不是觉得在释放一个数组对象的时候,应该使用delete []来取代delete?说真的,我的经验告诉我,没必要。现代的编译器可以很好的处理这个事情。

你:好?有考虑过C++的“rule of three“原则吗?你需要支持或是不允许复制这一类对象吗?

PS:

(来自维奇百科http://en.wikipedia.org/wiki/Rule_of_three_(C%2B%2B_programming)

The rule of three (also known asthe Law of The Big Three or The Big Three) is a rule of thumb in C++ that claimsthat if a class defines one
of the following itshould probably explicitly define all three:

§  destructor

§  copy constructor

§  assignment operator

也就是说,在C++中,如果需要显式定义析构函数、拷贝构造函数、赋值操作符中的一个,那么通常也会需要显式定义余下的两个。

 

第一个候选者:恩。无所谓了。听都没听说过tree-rule。当然,如果用户要拷贝这一类对象的话,会出现问题。但是,这也许就是C++的本质,给程序员无穷尽的噩梦。

顺便说一下,我想你应该知道哎C++中所有的析构函数都应该定义为virtual函数。我在一些书上看到过这个原则,这主要是为了防止在析构子类对象时候出现内存泄露。

你心里嘀咕:或是类似的玩意。Another ice cream perhaps?(我还是没搞明白这到底哪门情感)

 

令人愉悦的第二个候选者登场了:)

 

候选者:哦,我该从何说起呢?先关注一些比较重要的东西吧。

首先是析构函数。如果你使用了操作符new[],那么你就应该使用操作符delete[]进行析构。使用操作符delete[]的话,在数组中的每一个对象的析构函数被调用以后,所占用的内存会被释放。例如,如果像上面的代码那样写的话,B类的构造函数会被执行sz次,但是析构函数仅仅被调用1次。这个时候,如果B类的构造函数动态分配了内存,那么就是造成内存泄漏。

接下类,会谈到“rule of three”。如果你需要析构函数,那么你可能要么实现要么显式禁止拷贝构造函数和赋值操作符。由编译器生成的这两者中任何一个,很大可能不能正常工作。

还有一个小问题,但是也很重要。通常使用成员初始化列表来初始化一个对象。在上面的例子中,还体现不出来这样做的重要性。但是当成员对象比较复杂的时候,相比让对象隐式地使用默认值来初始化成员,然后在进行赋值操作来说,使用初始化列表显式初始化成员更为合理。

先把代码修改一下:)然后再进一步阐述问题。

你改善了一下代码,如下: 

#include "B.hpp"

class A
{
    public:
      A(int sz) { sz_ = sz; v = new B[sz_]; }
      ~A() { delete[] v; }
      //...
    private:
      A(const A&);
      A& operator=(const A&);
      //...
      B* v;
      int sz_;
};

这个时候,这位候选者(第二个)说:好多了。

你进一步改进,如下:

#include "B.hpp"

class A
{
    public:
      A(int sz) { sz_ = sz; v = new B[sz_]; }
      virtual ~A() { delete[] v; }
      //...
    private:
      A(const A&);
      A& operator=(const A&);
      //...
      B* v;
      int sz_;
};

 

第二位候选者忙说道:别着急,耐心点。

接着他说:在这样的一个类中,定义一个virtual的析构函数,有什么意义?这里没有虚函数,因此,如果以此作为基类,派生出一个类,有点不可理喻。我知道是有一些程序员把非虚类作为基类来设计继承体系,但是我真的觉得他们误解了面向对象技术的一个关键点。我建议你析构函数的virtual说明符去掉。virtual这个关键字,用在析构函数上的时候,他有这么个作用:指示这个class是否被设计成一个基类。存在virtual,那么表明这个class应该作为一个基类,那么这个class应该是一个virtual class。

还是改一下初始化列表的问题吧:)

 

于是代码被你修改为如下: 

#include "B.hpp"

class A
{
    public:
      A(int sz):sz_(sz), v(new B[sz_]) { }
      ~A() { delete[] v; }
      //...
    private:
      A(const A&);
      A& operator=(const A&);
      //...
      B* v;
      int sz_;
};

 

第二个候选者说:恩,有了初始化列表。但是,你有没有注意到由此有产生了新的问题?

你编译的时候使用了-Wall选项吗?你应该使用-Wextra、-pedantic还有-Weffc++选项。如果没有警告出现,你可能没有注意到这里发生的错误。但是如果你提高了警告级别,你会发现问题不少。

一个不错的经验法则是:总是按照成员被定义的顺序来书写初始化列表,也就是说,成员按照自己被定义的顺序来呗初始化。在这个例子中,当v(new B[sz_])执行的时候,sz_还没有被定义。然后,sz_被初始化为sz。

事实上,C++代码中,类似的事情太常见了。

 

你于是把代码修改为:

#include "B.hpp"

class A
{
    public:
      A(int sz):v(new B[sz]), sz_(sz) { }
      ~A() { delete[] v; }
      //...
    private:
      A(const A&);
      A& operator=(const A&);
      //...
      B* v;
      int sz_;
};

第二个候选者:现在好多了。还有什么需要改进的吗?接下来我会提到一些小问题。。。

在C++代码中,看到一个光秃秃的指针,不是一个好的迹象。很多好的C++程序员都会尽可能的避免这样使用指针。当然,例子中的v看起来有点像STL中的vector,或且差不多类似于此的东西。

对于你的私有变量,你貌似使用了一些不同的命名约定。在此,我的看法是,只要这些变量是私有的,你爱怎么命名就怎么命名。你可以使得你的变量全部以_作为后缀,或且遵循微软命名规范,m_作为前缀。但是,请你不要使用_作为前缀来命名你的变量,以免和C语言保留的命名规范、Posix以及编译器的命名规则相混淆:)

 

(待续)

抱歉!评论已关闭.