c/c++/c#, asm, java
歪酷博客
日 历
网志文件夹
· 所有网志
· 项目
· 研究
· 学习
· 标准
· 算法
· 体会
· 游戏
· 转载
· 读后感
· 未分类
搜 索
友 情 链 接
· 歪酷博客
· 管理我的Blog
· 我的中学
· GameDev
· GameRes
· CodeGuru
· 李开复的中国学生网站
· Bjarne Stroustrup's homepage
· Stan Lippman's BLog
· CHARLES PETZOLD
· Bruce Eckel
· Dennis M. Ritchie
· Scott Meyers
· Herb Sutter
· 侯捷先生主页
· 孟岩先生Blog
· 曾毅主页
· 荣耀先生主页
· 熊节先生Blog
· Code Project
· File Format
· Since1985
· 周星星的BLOG
· 韩磊的BLOG

订阅 RSS

0087445

歪酷博客

« 上一篇: 关于C++中的类型转换 下一篇: C/C++试题(附答案) »
Englep @ 2004-06-17 00:33

Q1: 这个简单的程序我该怎么写?

A1: 常常有人问我一些简单的程序该如何写,这在学期之初时尤其如此。一个典型的问题是:如何读入一些数字,做些处理(比如数学运算),然后输出……好吧好吧,这里我给出一个“通用示范程序”:

    #include<iostream>

    #include<vector>

    #include<algorithm>

    using namespace std;

 

    int main()

    {

         vector<double> v;

 

        double d;

         while(cin>>d) v.push_back(d);     // read elements

        if (!cin.eof()) {         // check if input failed

             cerr << "format error\n";

             return 1; // error return

         }

 

        cout << "read " << v.size() << " elements\n";

 

         reverse(v.begin(),v.end());

        cout << "elements in reverse order:\n";

        for (int i = 0; i<v.size(); ++i) cout << v << '\n';

 

        return 0; // success return

    }  

程序很简单,是吧。这里是对它的一些“观察报告”:

这是一个用标准C++写的程序,使用了标准库。标准库提供的功能都位于namespace std之中,使用标准库所需包含的头文件是不含.h扩展名的。

如果你在Windows下编译,你需要把编译选项设为“console application”。记住,你的源代码文件的扩展名必须为.cpp,否则编译器可能会把它当作C代码来处理。

主函数main()要返回一个整数。

将输入读入标准库提供的vector容器可以保证你不会犯“缓冲区溢出”之类错误——对于初学者来说,硬是要求“把输入读到一个数组之中,不许犯任何‘愚蠢的错误’”似乎有点过份了——如果你真能达到这样的要求,那你也不能算完全的初学者了。如果你不相信我的这个论断,那么请看看我写的《Learning Standard C++ as a New Language》一文。  

代码中“ !cin.eof() ”是用来测试输入流的格式的。具体而言,它测试读输入流的循环是否因遇到EOF而终止。如果不是,那说明输入格式不对(不全是数字)。还有细节地方不清楚,可以参看你使用的教材中关于“流状态”的章节。

Vector是知道它自己的大小的,所以不必自己清点输入了多少元素。

这个程序不含任何显式内存管理代码,也不会产生内存泄漏。Vector会自动配置内存,所以用户不必为此烦心。

关于如何读入字符串,请参阅后面的“我如何从标准输入中读取string”条目。

这个程序以EOF为输入终止的标志。如果你在UNIX上运行这个程序,可以用Ctrl-D输入EOF。但你用的Windows版本可能会含有一个bug,导致系统无法识别EOF字符。如果是这样,那么也许下面这个有稍许改动的程序更适合你:这个程序以单词“end”作为输入终结的标志。

    #include<iostream>

    #include<vector>

    #include<algorithm>

    #include<string>

    using namespace std;

 

    int main()

    {

         vector<double> v;

 

        double d;

         while(cin>>d) v.push_back(d);     // read elements

        if (!cin.eof()) {         // check if input failed

            cin.clear();         // clear error state

             string s;

             cin >> s;      // look for terminator string

             if (s != "end") {

                  cerr << "format error\n";

                  return 1; // error return

             }

        }

 

        cout << "read " << v.size() << " elements\n";

 

         reverse(v.begin(),v.end());

        cout << "elements in reverse order:\n";

        for (int i = 0; i<v.size(); ++i) cout << v << '\n';

 

        return 0; // success return

    }

The C++ Programming Language 》第三版中关于标准库的章节里有更多更详细例子,你可以通过它们学会如何使用标准库来“轻松完成简单任务”。  

Q2: 为何我编译一个程序要花那么多时间?

A2: 也许是你的编译器有点不太对头——它是不是年纪太大了,或者没有安装正确?也可能你的电脑该进博物馆了……对于这样的问题我可真是爱莫能助了。

不过,也有可能原因在于你的程序——看看你的程序设计还能不能改进?编译器是不是为了顺利产出正确的二进制码而不得不吃进成百个头文件、几万行的源代码?原则上,只要对源码适当优化一下,编译缓慢的问题应该可以解决。如果症结在于你的类库供应商,那么你大概除了“换一家类库供应商”外确实没什么可做的了;但如果问题在于你自己的代码,那么完全可以通过重构(refactoring)来让你的代码更为结构化,从而使源码一旦有更改时需重编译的代码量最小。这样的代码往往是更好的设计:因为它的藕合程度较低,可维护性较佳。

我们来看一个OOP的经典例子:

    class Shape {

    public:       // interface to users of Shapes

         virtual void draw() const;

         virtual void rotate(int degrees);

        // ...

    protected:     // common data (for implementers of Shapes)

        Point center;

        Color col;

        // ...

    };

 

    class Circle : public Shape {

    public:  

        void draw() const;

        void rotate(int) { }

        // ...

    protected:

       int radius;

        // ...

    };

 

    class Triangle : public Shape {

    public:  

        void draw() const;

        void rotate(int);

        // ...

    protected:

       Point a, b, c;

        // ...

    };     

上述代码展示的设计理念是:让用户通过Shape的公共界面来处理“各种形状”;而Shape的保护成员提供了各继承类(比如Circle,Triangle)共同需要的功能。也就是说:将各种形状(shapes)的公共因素划归到基类Shape中去。这种理念看来很合理,不过我要提请你注意:

要确认“哪些功能会被所有的继承类用到,而应在基类中实作”可不是件简单的事。所以,基类的保护成员或许会随着要求的变化而变化,其频度远高于公共界面之可能变化。例如,尽管我们把“center”作为所有形状的一个属性(从而在基类中声明)似乎是天经地义的,但因此而要在基类中时时维护三角形的中心坐标是很麻烦的,还不如只在需要时才计算——这样可以减少开销。

和抽象的公共界面不同,保护成员可能会依赖实作细节,而这是Shape类的使用者所不愿见到的。例如,绝大部分使用Shape的代码应该逻辑上和color无关;但只要color的声明在Shape类中出现了,就往往会导致编译器将定义了“该操作系统中颜色表示”的头文件读入、展开、编译。这都需要时间!

当基类中保护成员(比如前面说的center,color)的实作有所变化,那么所有使用了Shape类的代码都需要重新编译——哪怕这些代码中只有很少是真正要用到基类中的那个“语义变化了的保护成员”。

所以,在基类中放一些“对于继承类之实作有帮助”的功能或许是出于好意,但实则是麻烦的源泉。用户的要求是多变的,所以实作代码也是多变的。将多变的代码放在许多继承类都要用到的基类之中,那么变化可就不是局部的了,这会造成全局影响的!具体而言就是:基类所倚赖的一个头文件变动了,那么所有继承类所在的文件都需重新编译。

这样分析过后,解决之道就显而易见了:仅仅把基类用作为抽象的公共界面,而将“对继承类有用”的实作功能移出。

    class Shape {

    public:       // interface to users of Shapes

         virtual void draw() const = 0;

         virtual void rotate(int degrees) = 0;

         virtual Point center() const = 0;

        // ...

 

       // no data

    };

 

    class Circle : public Shape {

    public:  

        void draw() const;

        void rotate(int) { }

        Point center() const { return center; }

        // ...

    protected:

       Point cent;

       Color col;

       int radius;

        // ...

    };

 

    class Triangle : public Shape {

    public:  

        void draw() const;

        void rotate(int);

        Point center() const;

        // ...

    protected:

       Color col;

       Point a, b, c;

        // ...

    };     

这样,继承类的变化就被孤立起来了。由变化带来的重编译时间可以极为显著地缩短。

但是,如果确实有一些功能是要被所有继承类(或者仅仅几个继承类)共享的,又不想在每个继承类中重复这些代码,那怎么办?也好办:把这些功能封装成一个类,如果继承类要用到这些功能,就让它再继承这个类:

    class Shape {

    public:       // interface to users of Shapes

         virtual void draw() const = 0;

         virtual void rotate(int degrees) = 0;

         virtual Point center() const = 0;

        // ...

 

       // no data

    };

 

   struct Common {

       Color col;

       // ...

   };

        

    class Circle : public Shape, protected Common {

    public:  

        void draw() const;

        void rotate(int) { }

        Point center() const { return center; }

        // ...

    protected:

       Point cent;

       int radius;

    };

 

    class Triangle : public Shape, protected Common {

    public:  

        void draw() const;

        void rotate(int);

        Point center() const;

        // ...

    protected:

       Point a, b, c;

    };   

Q3: 为何空类的大小不是零?

A3: 为了确保两个不同对象的地址不同,必须如此。也正因为如此,new返回的指针总是指向不同的单个对象。我们还是来看代码吧:

    class Empty { };

 

    void f()

    {

       Empty a, b;

        if (&a == &b) cout << "impossible: report error to compiler supplier";

 

        Empty* p1 = new Empty;

        Empty* p2 = new Empty;

        if (p1 == p2) cout << "impossible: report error to compiler supplier";

    }      

另外,C++中有一条有趣的规则——空基类并不需要另外一个字节来表示:

    struct X : Empty {

        int a;

        // ...

    };

 

    void f(X* p)

    {

        void* p1 = p;

        void* p2 = &p->a;

        if (p1 == p2) cout << "nice: good optimizer";

    }  

如果上述代码中p1和p2相等,那么说明编译器作了优化。这样的优化是安全的,而且非常有用。它允许程序员用空类来表示非常简单的概念,而不需为此付出额外的(空间)代价。一些现代编译器提供了这种“虚基类优化”功能。

Q4: 为什么我必须把数据放到类的声明之中?

A4: 没人强迫你这么做。如果你不希望界面中有数据,那么就不要把它放在定义界面的类中,放到继承类中好了。参看“为何我编译一个程序要花那么多时间”条目。  

但也有的时候你确实需要把数据放到类声明里面,比如下面的复数类的例子:

    template<class Scalar> class complex {

    public:

         complex() : re(0), im(0) { }

         complex(Scalar r) : re(r), im(0) { }

         complex(Scalar r, Scalar i) : re(r), im(i) { }

        // ...

 

         complex& operator+=(const complex& a)

             { re+=a.re; im+=a.im; return *this; }

        // ...

    private:

        Scalar re, im;

    };  

这个complex(复数)类是被设计成像C++内置类型那样使用的,所以数据表示必须出现在声明之中,以便可以建立真正的本地对象(即在堆栈上分配的对象,而非在堆中分配),这同时也确保了简单操作能被正确内联化。“本地对象”和“内联”这两点很重要,因为这样才可以使我们的复数类达到和内置复数类型的语言相当的效率。

Q5: 为何成员函数不是默认为虚?

A5: 因为许多类不是被用来做基类的。例如,复数类就是如此。

另外,有虚函数的类有虚机制的开销,通常而言每个对象增加的空间开销是一个字长。这个开销可不小,而且会造成和其他语言(比如C,Fortran)的不兼容性——有虚函数的类的内存数据布局和普通的类是很不一样的。

The Design and Evolution of C++》 中有更多关于设计理念的细节。

Q6: 为何析构函数不是默认为虚?

A6: 哈,你大概知道我要说什么了 :O) 仍然是因为——许多类不是被用来做基类的。只有在类被作为interface使用时虚函数才有意义。(这样的类常常在内存堆上实例化对象并通过指针或引用访问。)

那么,何时我该让析构函数为虚呢?哦,答案是——当类有其它虚函数的时候,你就应该让析构函数为虚。有其它虚函数,就意味着这个类要被继承,就意味着它有点“interface”的味道了。这样一来,程序员就可能会以基类指针来指向由它的继承类所实例化而来的对象,而能否通过基类指针来正常释放这样的对象就要看析构函数是否为虚了。 例如:

    class Base {

        // ...

         virtual ~Base();

    };

 

    class Derived : public Base {

        // ...

         ~Derived();

    };

 

    void f()

    {

        Base* p = new Derived;

        delete p;   // virtual destructor used to ensure that ~Derived is called

    }  

如果Base的析构函数不是虚的,那么Derived的析构函数就不会被调用——这常常会带来恶果:比如,Derived中分配的资源没有被释放。

Q7: C++中为何没有虚拟构造函数?

A7: 虚拟机制的设计目的是使程序员在不完全了解细节(比如只知该类实现了某个界面,而不知该类确切是什么东东)的情况下也能使用对象。但是,要建立一个对象,可不能只知道“这大体上是什么”就完事——你必须完全了解全部细节,清楚地知道你要建立的对象是究竟什么。所以,构造函数当然不能是虚的了。  

不过有时在建立对象时也需要一定的间接性,这就需要用点技巧来实现了。(详见《The C++ Programming Language》,第三版,15.6.2)这样的技巧有时也被称作“虚拟构造函数”。我这里举个使用抽象类来“虚拟构造对象”的例子:

    struct F {     // interface to object creation functions

         virtual A* make_an_A() const = 0;

         virtual B* make_a_B() const = 0;

    };

 

    void user(const F& fac)

    {

        A* p = fac.make_an_A();     // make an A of the appropriate type

        B* q = fac.make_a_B();     // make a B of the appropriate type

        // ...

    }

 

    struct FX : F {

        A* make_an_A() const { return new AX(); } // AX is derived from A

        B* make_a_B() const { return new BX();     } // BX is derived from B

    

    };

 

    struct FY : F {

        A* make_an_A() const { return new AY(); } // AY is derived from A

        B* make_a_B() const { return new BY();     } // BY is derived from B

 

    };

 

    int main()

    {

         user(FX());     // this user makes AXs and BXs

         user(FY());     // this user makes AYs and BYs

        // ...

    }

 

看明白了没有?上述代码其实运用了Factory模式的一个变体。关键之处是,user()被完全孤立开了——它对AX,AY这些类一无所知。  

Q8: 为何无法在派生类中重载?

A8: 这个问题常常是由这样的例子中产生的:

    #include<iostream>

    using namespace std;

 

    class B {

    public:

        int f(int i) { cout << "f(int): "; return i+1; }

        // ...

    };

 

    class D : public B {

    public:

        double f(double d) { cout << "f(double): "; return d+1.3; }

        // ...

    };

 

    int main()

    {

        D* pd = new D;

 

        cout << pd->f(2) << '\n';

        cout << pd->f(2.3) << '\n';

    }  

程序运行结果是:

    f(double): 3.3
    f(double): 3.6

而不是某些人(错误地)猜想的那样:

    f(int): 3
    f(double): 3.6

换句话说,在D和B之间没有重载发生。你调用了pd->f(),编译器就在D的名字域里找啊找,找到double f(double)后就调用它了。编译器懒得再到B的名字域里去看看有没有哪个函数更符合要求。记住,在C++中,没有跨域重载——继承类和基类虽然关系很亲密,但也不能坏了这条规矩。详见《The Design and Evolution of C++》或者《The C++ Programming Language》第三版。

不过,如果你非得要跨域重载,也不是没有变通的方法——你就把那些函数弄到同一个域里来好了,使用一个using声明就可以了。

    class D : public B {

    public:

        using B::f;    // make every f from B available

        double f(double d) { cout << "f(double): "; return d+1.3; }

        // ...

    };

这样一来,结果就是

    f(int): 3

    f(double): 3.6

重载发生了——因为D中的那句 using B::f 明确告诉编译器,要把B域中的f引入当前域,请编译器“一视同仁”。

Q9: 我能从构造函数调用虚函数吗?

A9: 可以。不过你得悠着点。当你这样做时,也许你自己都不知道自己在干什么!在构造函数中,虚拟机制尚未发生作用,因为此时overriding尚未发生。万丈高楼平地起,总得先打地基吧?对象的建立也是这样——先把基类构造完毕,然后在此基础上构造派生类。  

看看这个例子:

    #include<string>

    #include<iostream>

    using namespace std;

 

    class B {

    public:

         B(const string& ss) { cout << "B constructor\n"; f(ss); }

         virtual void f(const string&) { cout << "B::f\n";}

    };

 

    class D : public B {

    public:

         D(const string & ss) :B(ss) { cout << "D constructor\n";}

        void f(const string& ss) { cout << "D::f\n"; s = ss; }

    private:

        string s;

    };

 

    int main()

    {

        D d("Hello");

    }

 

这段程序经编译运行,得到这样的结果:

    B constructor
    B::f
    D constructor

注意,输出不是D::f 。 究竟发生了什么?f()是在B::B()中调用的。如果构造函数中调用虚函数的规则不是如前文所述那样,而是如一些人希望的那样去调用D::f()。那么因为构造函数D:()尚未运行,字符串s还未初始化,所以当D::f()试图将参数赋给s时,结果多半是——立马当机。

析构则正相反,遵循从继承类到基类的顺序(拆房子总得从上往下拆吧?),所以其调用虚函数的行为和在构造函数中一样:虚函数此时此刻被绑定到哪里(当然应该是基类啦——因为继承类已经被“拆”了——析构了!),调用的就是哪个函数。

更多细节请见《The Design and Evolution of C++》,13.2.4.2 或者《The C++ Programming Language》第三版,15.4.3 。

有时,这条规则被解释为是由于编译器的实作造成的。但事实上不是这么一回事——让编译器实作成“构造函数中调用虚函数也和从其他函数中调用一样”是很简单的。关键还在于语言设计时的考量——让虚函数可以求助于基类提供的通用代码。

Q10: 有"placement delete"吗?

A10: 没有。不过如果你真的想要,你就说嘛——哦不,我的意思是——你可以自己写一个。  

我们来看看将对象放至某个指定场所的placement new:

 

    class Arena {
    public:
         void* allocate(size_t);
        void deallocate(void*);

        // ...
    };


    void* operator new(size_t sz, Arena& a)
    {
        return a.allocate(sz);
    }



    Arena a1(some arguments);
    Arena a2(some arguments);

 

现在我们可以写:

    X* p1 = new(a1) X;

    Y* p2 = new(a1) Y;

    Z* p3 = new(a2) Z;

    // ...

但之后我们如何正确删除这些对象?没有内置“placement delete”的理由是,没办法提供一个通用的placement delete。C++的类型系统没办法让我们推断出p1是指向被放置在a1中的对象。即使我们能够非常天才地推知这点,一个简单的指针赋值操作也会让我们重陷茫然。不过,程序员本人应该知道在他自己的程序中什么指向什么,所以可以有解决方案:

    template<class T> void destroy(T* p, Arena& a)

    {

        if (p) {

             p->~T();      // explicit destructor call

             a.deallocate(p);

        }

    }  

这样我们就可以写:  

 destroy(p1,a1);
 destroy(p2,a2);
 destroy(p3,a3);  

如果Arena自身跟踪放置其中的对象,那么你可以安全地写出destroy()函数 ,把“保证无错”的监控任务交给Arena,而不是自己承担。

如何在类继承体系中定义配对的operator new() 和 operator delete() 可以参看 《The C++ Programming Language》,Special Edition,15.6节 ,《The Design and Evolution of C++》,10.4节,以及《The C++ Programming Language》,Special Edition,19.4.5节。   

Q11: 我能防止别人从我的类继承吗?

A11: 可以的,但何必呢?好吧,也许有两个理由:

出于效率考虑——不希望我的函数调用是虚的

出于安全考虑——确保我的类不被用作基类(这样我拷贝对象时就不用担心对象被切割(slicing)了)

根据我的经验,“效率考虑”常常纯属多余。在C++中,虚函数调用如此之快,和普通函数调用并没有太多的区别。请注意,只有通过指针或者引用调用时才会启用虚拟机制;如果你指名道姓地调用一个对象,C++编译器会自动优化,去除任何的额外开销。

如果为了和“虚函数调用”说byebye,那么确实有给类继承体系“封顶”的需要。在设计前,不访先问问自己,这些函数为何要被设计成虚的。我确实见过这样的例子:性能要求苛刻的函数被设计成虚的,仅仅因为“我们习惯这样做”!

好了,无论如何,说了那么多,毕竟你只是想知道,为了某种合理的理由,你能不能防止别人继承你的类。答案是可以的。可惜,这里给出的解决之道不够干净利落。你不得不在在你的“封顶类”中虚拟继承一个无法构造的辅助基类。还是让例子来告诉我们一切吧:

    class Usable;

    class Usable_lock {
    friend class Usable;
    private:
         Usable_lock() {}
         Usable_lock(const Usable_lock&) {}
    };
      
    class Usable : public virtual Usable_lock {
    // ...
    public:
         Usable();
         Usable(char*);
        // ...
    };
      
    Usable a;
      
    class DD : public Usable { };
      
    DD dd;  // error: DD:D() cannot access
         // Usable_lock::Usable_lock(): private member  

(参见《The Design and Evolution of C++》,11.4.3节)

Q12: 为什么我无法限制模板的参数?

A12: 呃,其实你是可以的。而且这种做法并不难,也不需要什么超出常规的技巧。

让我们来看这段代码:

    template<class Container>

    void draw_all(Container& c)

    {

         for_each(c.begin(),c.end(),mem_fun(&Shape::draw));

    }

如果c不符合constraints,出现了类型错误,那么错误将发生在相当复杂的for_each解析之中。比如说,参数化的类型被要求实例化int型,那么我们无法为之调用Shape::draw()。而我们从编译器中得到的错误信息是含糊而令人迷惑的——因为它和标准库中复杂的for_each纠缠不清。

为了早点捕捉到这个错误,我们可以这样写代码:

    template<class Container>

    void draw_all(Container& c)

    {

         Shape* p = c.front(); // accept only containers of Shape*s

 

         for_each(c.begin(),c.end(),mem_fun(&Shape::draw));

    }

我们注意到,前面加了一行Shape *p的定义(尽管就程序本身而言,p是无用的)。如果不可将c.front()赋给Shape *p,那么就大多数现代编译器而言,我们都可以得到一条含义清晰的出错信息。这样的技巧在所有语言中都很常见,而且对于所有“不同寻常的构造”都不得不如此。   

不过这样做不是最好。如果要我来写实际代码,我也许会这样写:

    template<class Container>

    void draw_all(Container& c)

    {

         typedef typename Container::value_type T;

         Can_copy<T,Shape*>(); // accept containers of only Shape*s

 

         for_each(c.begin(),c.end(),mem_fun(&Shape::draw));

    }  

这就使代码通用且明显地体现出我的意图——我在使用断言。Can_copy()模板可被这样定义:

    template<class T1, class T2> struct Can_copy {

        static void constraints(T1 a, T2 b) { T2 c = a; b = a; }

         Can_copy() { void(*p)(T1,T2) = constraints; }

    };

Can_copy在编译期间检查确认T1可被赋于T2。Can_copy<T,Shape*>检查确认T是一个Shape*类型,或者是一个指向Shape的公有继承类的指针,或者是用户自定义的可被转型为Shape *的类型。注意,这里Can_copy()的实现已经基本上是最优化的了:一行代码用来指明需要检查的constraints,和要对其做这个检查的类型;一行代码用来精确列出所要检查是否满足的constraints(constraints()函数);一行代码用来提供执行这些检查的机会。  

请大家再注意,现在我们的定义具备了这些我们需要的特性:

你可以不通过定义/拷贝变量就表达出constraints,从而可以不必作任何“那个类型是这样被初始化”之类假设,也不用去管对象能否被拷贝、销毁(除非这正是constraints所在)。

如果使用现代编译器,constraints不会带来任何额外代码

定义或者使用constraints均不需使用宏定义

如果constraints没有被满足,编译器给出的错误消息是容易理解的。事实上,给出的错误消息包括了单词“constraints” (这样,编码者就能从中得到提示)、constraints的名称、具体的出错原因(比如“cannot initialize Shape* by double*”)

既然如此,我们干吗不干脆在C++语言本身中定义类似Can_copy()或者更优雅简洁的语法呢?The Design and Evolution of C++分析了此做法带来的困难。已经有许许多多设计理念浮出水面,只为了让含constraints的模板类易于撰写,同时还要让编译器在constraints不被满足时给出容易理解的出错消息。比方说,我在Can_copy中“使用函数指针”的设计就来自于Alex Stepanov和Jeremy Siek。我认为我的Can_copy()实作还不到可以标准化的程度——它需要更多实践的检验。另外,C++使用者会遭遇许多不同类型的constraints,目前看来还没有哪种形式的带constraints的模板获得压倒多数的支持。

已有不少关于constraints的“内置语言支持”方案被提议和实作。但其实要表述constraint根本不需要什么异乎寻常的东西:毕竟,当我们写一个模板时,我们拥有C++带给我们的强有力的表达能力。让代码来为我的话作证吧:

    template<class T, class B> struct Derived_from {

        static void constraints(T* p) { B* pb = p; }

         Derived_from() { void(*p)(T*) = constraints; }

    };

 

    template<class T1, class T2> struct Can_copy {

        static void constraints(T1 a, T2 b) { T2 c = a; b = a; }

         Can_copy() { void(*p)(T1,T2) = constraints; }

    };

 

    template<class T1, class T2 = T1> struct Can_compare {

        static void constraints(T1 a, T2 b) { a==b; a!=b; a<b; }

         Can_compare() { void(*p)(T1,T2) = constraints; }

    };

 

    template<class T1, class T2, class T3 = T1> struct Can_multiply {

        static void constraints(T1 a, T2 b, T3 c) { c = a*b; }

         Can_multiply() { void(*p)(T1,T2,T3) = constraints; }

    };

 

    struct B { };

    struct D : B { };

    struct DD : D { };

    struct X { };

 

    int main()

    {

         Derived_from<D,B>();

         Derived_from<DD,B>();

         Derived_from<X,B>();

         Derived_from<int,B>();

         Derived_from<X,int>();

 

         Can_compare<int,float>();

         Can_compare<X,B>();

         Can_multiply<int,float>();

         Can_multiply<int,float,double>();

         Can_multiply<B,X>();

    

         Can_copy<D*,B*>();

         Can_copy<D,B*>();

         Can_copy<int,B*>();

    }

 

    // the classical "elements must derived from Mybase*" constraint:

 

    template<class T> class Container : Derived_from<T,Mybase> {

        // ...

    };

事实上Derived_from并不检查继承性,而是检查可转换性。不过Derive_from常常是一个更好的名字——有时给constraints起个好名字也是件需细细考量的活儿。

Q13: 我们已经有了 "美好的老qsort()",为什么还要用sort()?

A13: 对于初学者而言,

    qsort(array,asize,sizeof(elem),elem_compare);  

看上去有点古怪。还是

    sort(vec.begin(),vec.end());  

比较好理解,是吧。那么,这点理由就足够让你舍qsort而追求sort了。对于老手来说,sort()要比qsort()快的事实也会让你心动不已。而且sort是泛型的,可以用于任何合理的容器组合、元素类型和比较算法。例如:

    struct Record {

        string name;

        // ...

    };

 

    struct name_compare {     // compare Records using "name" as the key

        bool operator()(const Record& a, const Record& b) const

             { return a.name<b.name; }

    };

 

    void f(vector<Record>& vs)

    {

         sort(vs.begin(), vs.end(), name_compare());

        // ...

    }    

另外,还有许多人欣赏sort()的类型安全性——要使用它可不需要任何强制的类型转换。对于标准类型,也不必写compare()函数,省事不少。如果想看更详尽的解释,参看我的《Learning Standard C++ as a New Language》一文。

另外,为何sort()要比qsort()快?因为它更好地利用了C++的内联语法语义。

Q14: 什么是function object?

A14: Function object是一个对象,不过它的行为表现像函数。一般而言,它是由一个重载了operator()的类所实例化得来的对象。

Function object的涵义比通常意义上的函数更广泛,因为它可以在多次调用之间保持某种“状态”——这和静态局部变量有异曲同工之妙;不过这种“状态”还可以被初始化,还可以从外面来检测,这可要比静态局部变量强了。我们来看一个例子:

    class Sum {

        int val;

    public:

         Sum(int i) :val(i) { }

         operator int() const { return val; }         // extract value

 

        int operator()(int i) { return val+=i; }     // application

    };

 

    void f(vector v)

    {

        Sum s = 0;   // initial value 0

        s = for_each(v.begin(), v.end(), s);     // gather the sum of all elements

        cout << "the sum is " << s << "\n";

    

        // or even:

        cout << "the sum is " << for_each(v.begin(), v.end(), Sum(0)) << "\n";

    }  

这里我要提请大家注意:一个function object可被漂亮地内联化(inlining),因为对于编译器而言,没有讨厌的指针来混淆视听,所以这样的优化很容易进行。作为对比,编译器几乎不可能通过优化将“通过函数指针调用函数”这一步骤所花的开销省掉,至少目前如此。

在标准库中function objects被广泛使用,这给标准库带来了极大的灵活性和可扩展性。

Q15: 我应该怎样处理内存泄漏?

A15: 很简单,只要写“不漏”的代码就完事了啊。显然,如果你的代码到处是new、delete、指针运算,那你想让它“不漏”都难。不管你有多么小心谨慎,君为人,非神也,错误在所难免。最终你会被自己越来越复杂的代码逼疯的——你将投身于与内存泄漏的奋斗之中,对bug们不离不弃,直至山峰没有棱角,地球不再转动。而能让你避免这样困境的技巧也不复杂:你只要倚重隐含在幕后的分配机制——构造和析构,让C++的强大的类系统来助你一臂之力就OK了。标准库中的那些容器就是很好的实例。它们让你不必化费大量的时间精力也能轻松惬意地管理内存。我们来看看下面的示例代码——设想一下,如果没有了string和vector,世界将会怎样?如果不用它们,你能第一次就写出毫无内存错误的同样功能代码吗?

    #include<vector>

    #include<string>

    #include<iostream>

    #include<algorithm>

    using namespace std;

 

    int main()     // small program messing around with strings

    {

        cout << "enter some whitespace-separated words:\n";

         vector<string> v;

        string s;

        while (cin>>s) v.push_back(s);

 

         sort(v.begin(),v.end());

 

        string cat;

         typedef vector<string>::const_iterator Iter;

        for (Iter p = v.begin(); p!=v.end(); ++p) cat += *p+"+";

        cout << cat << '\n';

    }

请注意这里没有显式的内存管理代码。没有宏,没有类型转换,没有溢出检测,没有强制的大小限制,也没有指针。如果使用function object和标准算法,我连Iterator也可以不用。不过这毕竟只是一个小程序,杀鸡焉用牛刀?

当然,这些方法也并非无懈可击,而且说起来容易做起来难,要系统地使用它们也并不总是很简单。不过,无论如何,它们的广泛适用性令人惊讶,而且通过移去大量的显式内存分配/释放代码,它们确实增强了代码的可读性和可管理性。早在1981年,我就指出通过大幅度减少需要显式加以管理的对象数量,使用C++“将事情做对”将不再是一件极其费神的艰巨任务。

如果你的应用领域没有能在内存管理方面助你一臂之力的类库,那么如果你还想让你的软件开发变得既快捷又能轻松得到正确结果,最好是先建立这样一个库。

如果你无法让内存分配和释放成为对象的“自然行为”,那么至少你可以通过使用资源句柄来尽量避免内存泄漏。这里是一个示例:假设你需要从函数返回一个对象,这个对象是在自由内存堆上分配的;你可能会忘记释放那个对象——毕竟我们无法通过检查指针来确定其指向的对象是否需要被释放,我们也无法得知谁应该负责释放它。那么,就用资源句柄吧。比如,标准库中的auto_ptr就可以帮助澄清:“释放对象”责任究竟在谁。我们来看:

    #include<memory>

    #include<iostream>

    using namespace std;

 

    struct S {

        S() { cout << "make an S\n"; }

        ~S() { cout << "destroy an S\n"; }

         S(const S&) { cout << "copy initialize an S\n"; }

        S& operator=(const S&) { cout << "copy assign an S\n"; }

    };

 

    S* f()

    {

         return new S;  // who is responsible for deleting this S?

    };

 

    auto_ptr<S> g()

    {

         return auto_ptr<S>(new S);  // explicitly transfer responsibility for deleting this S

    }

 

    int main()

    {

        cout << "start main\n";

        S* p = f();

        cout << "after f() before g()\n";

    //   S* q = g(); // caught by compiler

         auto_ptr<S> q = g();

        cout << "exit main\n";

        // leaks *p

        // implicitly deletes *q

    }

这里只是内存资源管理的例子;至于其它类型的资源管理,可以如法炮制。

如果在你的开发环境中无法系统地使用这种方法(比方说,你使用了第三方提供的古董代码,或者远古“穴居人”参与了你的项目开发),那么你在开发过程中可千万要记住使用内存防漏检测程序,或者干脆使用垃圾收集器(Garbage Collector)。

Q16: 为何捕捉到异常后不能继续执行后面的代码呢?

A16: 这个问题,换句话说也就是:为什么C++不提供这样一个原语,能使你处理异常过后返回到异常抛出处继续往下执行?

嗯,从异常处理代码返回到异常抛出处继续执行后面的代码的想法很好,但主要问题在于——exception handler不可能知道为了让后面的代码正常运行,需要做多少清除异常的工作,所以,如果要让“继续执行”能够正常工作,写throw代码的人和写catch代码的人必须对彼此的代码都很熟悉,而这就带来了复杂的相互依赖关系,会带来很多麻烦的维护问题。

在我设计C++的异常处理机制的时候,我曾认真地考虑过这个问题;在C++标准化的过程中,这个问题也被详细地讨论过。(参见《The Design and Evolution of C++》中关于异常处理的章节)如果你想试试看在抛出异常之前能不能解决问题然后继续往下执行,你可以先调用一个“检查—恢复”函数,然后,如果还是不能解决问题,再把异常抛出。一个这样的例子是new_handler。

Q17: 为何C++中没有C中realloc()的对应物?

A17: 如果你一定想要的话,你当然可以使用realloc()。不过,realloc() 只和通过malloc()之类C函数分配得到的内存“合作愉快”,在分配的内存中不能有具备用户自定义构造函数的对象。请记住:与某些天真的人们的想象相反,realloc()必要时是会拷贝大块的内存到新分配的连续空间中的。所以,realloc没什么好的 ^_^

在C++中,处理内存重分配的较好办法是使用标准库中的容器,比如vector。

Q18: 我如何使用异常处理?

A18: 参见《The C++ Programming Language》14章8.3节,以及附录E。附录E主要阐述如何撰写“exception-safe”代码,这个附录可不是写给初学者看的。一个关键技巧是“资源分配即初始化”——这种技巧通过“类的析构函数”给易造成混乱的“资源管理”带来了“秩序的曙光”。

Q19: 我如何从标准输入中读取string?

A19: 如果要读以空白结束的单个单词,可以这样:

    #include<iostream>

    #include<string>

    using namespace std;

 

    int main()

    {

        cout << "Please enter a word:\n";

 

        string s;

         cin>>s;

    

        cout << "You entered " << s << '\n';

    }  

请注意,这里没有显式的内存管理代码,也没有限制尺寸而可能会不小心溢出的缓冲区。

如果你需要一次读一整行,可以这样:

    #include<iostream>

    #include<string>

    using namespace std;

 

    int main()

    {

        cout << "Please enter a line:\n";

 

        string s;

         getline(cin, s);

    

        cout << "You entered " << s << '\n';

    }  

关于标准库所提供之功能的简介(诸如iostream,stream),参见《The C++ Programming Language》第三版的第三章。如果想看C和C++的输入输出功能使用之具体比较,参看我的《Learning Standard C++ as a New Language》一文。

Q20: 为何C++不提供“finally”结构?

A20: 因为C++提供了另一种机制,完全可以取代finally,而且这种机制几乎总要比finally工作得更好:就是——“分配资源即初始化”。(见《The C++ Programming Language》14.4节)基本的想法是,用一个局部对象来封装一个资源,这样一来局部对象的析构函数就可以自动释放资源。这样,程序员就不会“忘记释放资源”了。下面是一个例子:

    class File_handle {

        FILE* p;

    public:

         File_handle(const char* n, const char* a)

             { p = fopen(n,a); if (p==0) throw Open_error(errno); }

         File_handle(FILE* pp)

             { p = pp; if (p==0) throw Open_error(errno); }

 

         ~File_handle() { fclose(p); }

 

         operator FILE*() { return p; }

 

        // ...

    };

 

    void f(const char* fn)

    {

         File_handle f(fn,"rw");   // open fn for reading and writing

        // use file through f

    }  

在一个系统中,每一样资源都需要一个“资源局柄”对象,但我们不必为每一个资源都写一个“finally”语句。在实作的系统中,资源的获取和释放的次数远远多于资源的种类,所以“资源分配即初始化”机制产生的代码要比“finally”机制少。  

另外,请看看《The C++ Programming Language》附录E中的资源管理例子。

Q21: 那个auto_ptr是什么东东啊?为什么没有auto_array?

A21: 哦,auto_ptr是一个很简单的资源封装类,是在<memory>头文件中定义的。它使用“资源分配即初始化”技术来保证资源在发生异常时也能被安全释放(“exception safety”)。一个auto_ptr封装了一个指针,也可以被当作指针来使用。当其生命周期到了尽头,auto_ptr会自动释放指针。例如:

    #include<memory>

    using namespace std;

 

    struct X {

        int m;

        // ..

    };

 

    void f()

    {

         auto_ptr<X> p(new X);

        X* q = new X;

 

        p->m++;         // use p just like a pointer

         q->m++;

        // ...

 

        delete q;

    }

如果在代码用// ...标注的地方抛出异常,那么p会被正常删除——这个功劳应该记在auto_ptr的析构函数头上。不过,q指向的X类型对象就没有被释放(因为不是用auto_ptr定义的)。详情请见《The C++ Programming Language》14.4.2节。

Auto_ptr是一个轻量级的类,没有引入引用计数机制。如果你把一个auto_ptr(比如,ap1)赋给另一个auto_ptr(比如,ap2),那么ap2将持有实际指针,而ap1将持有零指针。例如:

    #include<memory>

    #include<iostream>

    using namespace std;

 

    struct X {

        int m;

        // ..

    };

 

    int main()

    {

         auto_ptr<X> p(new X);

         auto_ptr<X> q(p);

        cout << "p " << p.get() << " q " << q.get() << "\n";

    }  

运行结果应该是先显示一个零指针,然后才是一个实际指针,就像这样:

    p 0x0 q 0x378d0  

auto_ptr::get()返回实际指针。

这里,语义似乎是“转移”,而非“拷贝”,这或许有点令人惊讶。特别要注意的是,不要把auto_ptr作为标准容器的参数——标准容器要求通常的拷贝语义。例如:

    std::vector<auto_ptr<X> >v;     // error

一个auto_ptr只能持有指向单个元素的指针,而不是数组指针:

    void f(int n)

    {

         auto_ptr<X> p(new X[n]);  // error

        // ...

    }  

上述代码会出错,因为析构函数是使用delete而非delete[]来释放指针的,所以后面的n-1个X没有被释放。

那么,看来我们应该用一个使用delete[]来释放指针的,叫auto_array的类似东东来放数组了?哦,不,不,没有什么auto_array。理由是,不需要有啊——我们完全可以用vector嘛:

    void f(int n)

    {

         vector<X> v(n);

        // ...

    }  

如果在 // ... 部分发生了异常,v的析构函数会被自动调用。

Q22: C和C++风格的内存分配/释放可以混用吗?

A22: 可以——从你可在一个程序中同时使用malloc()和new的意义上而言。



评论 / 个人网页 / 扔小纸条
*昵称

已经注册过? 请登录

Email
网址
*评论