欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

[C++]实现--讨论关于实现中可能出现的问题

程序员文章站 2022-10-30 20:30:55
实现 大多数情况下,适当地提出你的class和class template定义以及function和function template声明是花费最多心力的两件事情。一旦正确地完成他们,相当的实现就...

实现

大多数情况下,适当地提出你的class和class template定义以及function和function template声明是花费最多心力的两件事情。一旦正确地完成他们,相当的实现就简单的多了,但实际上还存在少量问题值得注意。

1. 尽可能延后变量定义式的出现时间

不要太早地声明变量,因为你可能永远也用不到这个变量。

函数异常终止

void test(std::string& password) {
    std::string encrypted;
    if (password.length() < minimumpasswordlength) {
        throw logic_error("password is too short");
    }
    ...

encrypted变量在函数抛出异常时,还没有被使用就已经失效了。虽然一个变量对象所占的内存并不会太多,但这是一种注重效率的思维方式,仍然值得提及。
合理的做法应该等到需要使用encrypted时才声明它。

void test(std::string& password) {
    if (password.length() < minimumpasswordlength) {
        throw logic_error("password is too short");
    }
    std::string encrypted;
    ...

初始化的方法

初始化一个对象很多种方法,但不同的方法效率是不一样的。我们总是愿意使用效率更高的方法,例如初始化一个对象,不要先使用缺省构造函数,然后再赋值,而是直接使用复制构造函数直接初始化对象,可以节省很多地开销。

循环

在循环中使用对象,此时应该如何声明对象?存在两种方法,一种是在循环内声明对象,另一种是在循环外声明对象。这两种方法的效率问题有值得讨论的地方。

两种方法的成本为:

1) n个构造函数和n个析构函数 2)1个构造函数+1个析构函数+n个赋值操作

如果class的一个赋值成本低于一组构造+析构成本,那么使用方法2总是更有效的,否则方法1更好。但是方法2变量的作用域更大,有时对程序的可理解性和易维护性造成冲击。因而除非1)你知道赋值成本比构造+析构成本低,2)你正在处理代码中效率高度敏感的部分,否则应该使用做法1。

2. 尽量少做转型动作

c++规则的设计目标之一是,保证“类型错误”绝不可能发生。理论上如果你的程序“很干净地”通过编译,就表示它并不会企图在任何对象上执行任何不安全、无意义、愚蠢荒谬的操作。这还是一个极具价值的保证!

然而,不幸的是,转型破坏了类型,那可能会导致一些麻烦,有些容易辨识,有些非常隐晦。本节讨论如何减少使用转型操作来使代码更有合理、高效。

c++转型机制

关于四种转型方式的区别详情见前文

基类指针指向子类

许多程序员相信,转型其实什么都没做,只是告诉编译器把某种类型视为另一种类型。但这是错误的观念。任何一种类型转换的(不论是显示的还是隐式的)往往真的令编译器编译出运行期间执行的码。就算是把int转型为double,因为在内存中int的存储方式和double的存储方式是截然不同的。

又例如我们可能会让一个基类指针指向一个子类:

class base { ... };
class derived : public base { ... };
derived d;
base* pb = &d;

在这里我们不过是建立了一个base class指针指向一个derived class对象,但有时候上述两个指针值并不相同。这种情况下会有个偏移量(offset)在运行期被执行于dereived*指针上,用以取得正确的base*指针值。所以单一对象可能拥有一个以上的地址,这就意味着你总是需要避免做出基于”对象在c++中如何布局“的假设来执行相应的转型操作,因为不同编译器对于不同对象布局方式和他们的地址计算方式是不同的。

在子类中调用基类函数

另一个似是而非的代码出现在我们希望在子类函数中调用基类函数,如:

class window {
public:
    virtual void onresize() { ... }
    ...
};
class speacialwindow {
public:
    virtual void onresize() {
        static_cast(*this).onresize();  //  企图调用基类相关函数。
        ...
    }
    ...
};

代码中强调了了转型动作,把this指针暂时转型为基类指针。当然没问题,的确调用了基类的函数,但问题仍然存在。实际上调用的并不是当前对象上的函数,而是稍早转型动作所建立的一个”*this对象之base class成分“的暂时副本身上的onresize!如果这个函数不对数据成员起影响当然就没有问题,但如果会改变数据成员就会出现很大的问题。

解决方法是去掉转型操作:

class speacialwindow {
public:
    virtual void onresize() {
        window::onresize();  // 通过作用符来强制调用基类的函数。
        ...
    }
    ...
};

避免使用dynamic_cast

问题产生

class window { ... };
class specialwindow : public window {
public:
    void blink();
    ...
};
typedef std::vector > vpw;
vpw winptrs;
...
for (vpw::iterator iter = winptrs.begin();
    iter != winptrys.end(); iter++) {
    if (specialwindow* psw = dynamic_cast(iter->get()))
    psw->blink();
}

之所以需要dynamic_cast,通常是因为你想在一个你认定为derived class对象身上执行dereived class操作函数,但你只有一个纸箱base的pointer/reference。但实际上,还是存在方法避免这个问题的。

在介绍解决方法之前,我们还需要强调,使用dynamic_cast是非常慢的!特别是在多种继承和深度继承时。因为他需要不断地寻找合适的class。

解放方法1:不使用base指针/reference

class window { ... };
class specialwindow : public window {
public:
    void blink();
    ...
};
typedef std::vector > vpsw;
vpsw winptrs;
...
for (vpw::iterator iter = winptrs.begin();
    iter != winptrys.end(); iter++) {
    (*iter)->blink();
}

这里直接在容器内存放子类的指针。

解决方法2:virtual函数实现多态

但其实上,你不可能期望能在同一个容日内存储指针”指向所有可能之各种window派生类“。所以更加合理的做法实际上是运用多态,实现动态绑定。

class window {
public:
    virtual void blink() {  }
    ...
};
class specialwindow : public window {
public:
    virtual void blink();
    ...
};
typedef std::vector > vpw;
vpw winptrs;
...
for (vpw::iterator iter = winptrs.begin();
    iter != winptrys.end(); iter++) {
    (*iter)->blink();
}

总结

绝对要避免的就是使用一连串的dynamic_cast!因为这样会造成代码运行得很慢。如果要实现从基类指针到子类指针的转变还是应该使用virtual函数,实现多态。

另外值得注意的是,如果转型是必要的,试着把它隐藏在某个函数背后。客户随后可以调用该函数,而不需要将转型放进他们的代码内。

3. 避免返回handles指向对象内部成分

reference、pointer、iterator统统都是所谓的handles,而返回一个“代表对象内部数据”的handle,随之而来的就是“降低对象封装性”的风险。

问题产生1

假设我需要做一个表示矩型的类:

class point {
public:
    point(int x, int y);
    ...
    void sety(int newy);
    void setx(int newx);
    ...
};
struct rectdata {
    point ulhc; // upper left-hand corner;
    point lrhc; // lower right-hand corner;
};
class rectangle {
    ...
    // 返回reference比返回value效率更高,避免了复制构造,但也出现了问题。
    point& upperleft() const { return pdata->ulhc; }
    point& lowerright() const { return pdata->lrhc; }
private:
    std:shared_ptr pdata;
};

这样的编译虽然没有问题,但是实际上却是错误,因为他们隐含危机。虽然upperleft和lowerright被声明为const成员函数,因为他们的目的只是为了提供客户一个得知rectangle相关坐标点的方法,但不让客户修改rectangle,但另一方面却又返回reference指向private内部数据,调用者甚至可以通过这些reference更改内部数据!

这立刻带给我们两个教训。第一,成员变量的封装性最多只等于“返回其reference”的函数的访问级别(就像本例中的private成员变量实际上变成了public,因为通过reference函数修改)。第二,如果const成员函数返回一个reference,后者所指数据与对象自身有关联,而他们又被存储于对象之外,那么这个函数的调用者可以修改那笔数据。

解决方法1

解决方法其实很简单,一种方法是不传递reference,但这就意味着降低效率,另一种方法是把reference变成const。

class rectangle {
    ...
    const point& upperleft() const { return pdata->ulhc; }
    const point& lowerright() const { return pdata->lrhc; }
private:
    std:shared_ptr pdata;
};

问题产生2

即使有了上述的叙述,问题仍然有可能存在。更准确的说,就是它可能导致dangling handles(空悬的handle),这种handles所指的东西(的所属对象)不复存在。这种“不复存在的对象”最常见的来源就是函数返回值。例如:

class guiobject { ... };
const rectangle boundingbox(const guiobject& obj);
...
// in main..
guiobject* pgo; // 随后会让pgo指向某个guiobject
...
const point* pupperleft = &(boundingbox(*pgo).upperleft());  // error occurs!

对boundingbox的调用将获得一个新的、暂时的、rectangle对象。对个对象是个临时对象temp。随后upperleft作用于temp身上,返回一个reference指向temp的一个内部成分,就是point。但问题开始出现了。因为在upperleft语句结束之后,boundingbox的返回值,也就是temp,将被销毁,而那间接导致temp内的point析构,最后导致pupperleft指向一个不再存在的对象。所以pupperleft就变成空悬的了。

问题的关键在于,有个handles被传出去了,一旦如此你就是暴露在“handle比其所指对象更长寿”的风险!

问题解决2

问题的解决方法就是不返回handle,就算这会导致一些效率地降低,但他总是能及时的被复制在另一个临时变量中,然后被成功传值,而不会出现上述导致空悬handle的问题。

当然这也并不意味着你不能让成员函数返回handle,在某些容器中就允许返回reference。例如operator[]允许返回容器内部数据。尽管如此,这也只是例外,不是常态。

所以,请记住:

避免返回handle指向对象内部。这样你就可以增加封装性,帮助const成员函数的行为像个const,并将发生“dangling handle”的可能性降到最低。

4. 透彻了解inline

调用inline函数可以无需蒙受函数调用所招致的额外开销,如此有用的功能却也有许多东西值得注意。

inline实现原理

编译器最优化机制通常被设计用来浓缩那些“不含函数调用”的代码,所以当你inline某个函数,或许编译器就因此有能力对它执行语境相关最优化。而大部分编译器都不会对“outlined函数调用”动作执行如此之最优化。

然而,inline背后的整体观念是,将“对此函数的每一个调用”都以函数本体替换之。这样做可能增加你的目标码大小,并导致效率损失。但如果函数本体很小,编译器针对“函数本体”所产出的码可能比针对“函数调用”所产出的码更小,那么效率反而会增加。

记住,inline只是对编译器的一个申请,不是强制命令。这项申请可以隐喻提出,也可以明确提出。隐喻提出是将函数定义在class定义式内(friend函数也可被定义于class内,那么他们也是隐喻声明为inline)。明确提出,是在函数前面加关键字inline。

inline函数通常一定被置于头文件内,因为大多数建置环境(build environments)在编译过程中进行inlining,而为了将一个函数调用替换为被调用函数的本体,编译器必须知道那个函数长什么样。

template通常也被置于头文件内,因为它一旦被使用,编译器为了将它具体化,需要知道它长什么样。

但是template得具体化和inlining无关。如果你认为你所写的template,所有根据此template具体出来的函数都应该inline,就将此函数声明为inline,否则,不要这样这么做。

谁应该是inline

大部分编译器拒绝太过复杂(带有循环或递归)的函数inlining,而所有对virtual函数的调用inline都会落空。因为virtual意味着“等待”,在程序运行时才会决定哪个virtual函数被调用,而inline却意味着在编译期间就先将调用动作替换为被调用函数的本体。

有时候虽然编译器愿意inlining某个函数,但还是有可能为该函数生成一个函数本体。例如程序要取某个inline函数的地址,编译器通常必须为此函数生成一个outlined函数本体。毕竟编译器哪有能力提出一个指针指向并不存在的函数呢?

inline void f() { ... }
void (*pf)() = f;
...
f();
pf();  // 这个调用或许不被inlined,因为它通过函数指针完成。

在我们决定哪些函数被声明为inline而哪些函数不应该时,我们应该掌握一个合乎逻辑的策略。一开始先不要将任何函数声明为inline,或至少将inline施行范围局限在哪些“一定称为inline”或“十分平淡无奇”的函数身上。慎重使用inline便是对日后使用调试器带来帮助,不过这么一来也等于把自己推向手工最优化之路。

不要忘记80-20经验法则:平均而言,一个程序往往将80%的执行时间花费在20%的代码上。这是一个重要的法则,它提醒着我们,作为一个软件开发者,我们的目标是找出可以有效增进程序整体效率的20%代码,然后将它inline或竭尽所能地将它瘦身。