滥用构造函数和析构函数来产生副作用是一种不好的实践吗?有替代方案吗?

22
在OpenGL中,人们经常会编写类似下面的代码:
glPushMatrix();
// modify the current matrix and use it
glPopMatrix();

基本上,状态被改变,然后执行一些使用新状态的操作,最后还原状态。

现在有两个问题:

  1. 很容易忘记还原状态。
  2. 如果中间的代码抛出异常,状态就永远不会被还原。

按照真正的面向对象编程风格,我编写了一些实用类来解决这些问题,如下所示:

struct WithPushedMatrix {
    WithPushedMatrix() { glPushMatrix(); }
    ~WithPushedMatrix() { glPopMatrix(); }
};

现在我可以简单地像这样写出之前的例子:

WithPushedMatrix p;
// modify the current matrix and use it

p的生命周期决定了恢复状态的确切时刻。如果抛出异常,p的析构函数将被调用,状态将被恢复,一切都很好。

尽管如此,我并不完全满意。特别是如果构造函数需要一些参数(例如glEnable的标志),很容易忘记将对象赋值给一个变量:

WithEnabledFlags(GL_BLEND); // whoops!

临时变量会立即销毁,状态更改也会被过早地撤销。

另一个问题是其他人阅读我的代码可能会感到困惑:“为什么在这里声明了一个从未被使用的变量?让我们将其去掉!”

所以,我的问题是:这是一个好的模式吗?它甚至可能有一个名字吗?这种方法存在任何我忽视的问题吗?最后但并非最不重要的:是否有任何好的替代方案?

更新:是的,我想这是 RAII 的一种形式。但它不是通常使用 RAII 的方式,因为它涉及一个看似无用的变量;所谓的“资源”从未被显式访问。我只是没有意识到这种用法如此普遍。


13
那么,您拥有一枚银色的“C++标签”徽章,却从未听说过RAII? - foraidt
@mxp:你刚才说的那个观察很,呃,有趣。 - sbi
1
虽然我不鼓励这样做(名称很好),但你可以获取模板来完全消除名称。这里是一个示例实现(最终版本在底部),GCC可以完全优化它。 - Johannes Schaub - litb
@mxp:是的,我有。严格来说,它不会被称为RAII,因为没有资源被获取。但话说回来,RAII本来就是一个用词不当的说法。 - Thomas
1
严格来说,是的,没有明显获取实体。但请考虑以下情况:计算机通过更改状态来运行。例如,当您创建位图(并将其绑定到包装类实例)时,会导致状态更改。同样,在这里也是如此-您“推动矩阵”(不知道确切发生了什么),这会改变状态。这就是为什么创建位图和“推动矩阵”以相同的方式与资源管理相关联的原因。 - sharptooth
10个回答

24
我喜欢使用RAII控制OpenGL状态的想法,但我实际上会更进一步:让你的WithFoo类构造函数接受一个函数指针作为参数,其中包含你想在该上下文中执行的代码。然后不要创建命名变量,只需使用临时变量,在lambda中传递你想要在该上下文中执行的操作。(当然需要C++0x - 也可以使用常规函数指针,但不够优美。)
像这样:(编辑以恢复异常安全性)
class WithPushedMatrix
{
public:
    WithPushedMatrix()
    {
        glPushMatrix();
    }

    ~WithPushedMatrix()
    {
        glPopMatrix();
    }

    template <typename Func>
    void Execute(Func action)
    {
        action();
    }
};

然后像这样使用:

WithPushedMatrix().Execute([]
{
    glBegin(GL_LINES);
    //etc. etc.
});

临时对象将设置您的状态,执行操作,然后自动拆除;您不必担心“松散”的状态变量漂浮,而在上下文中执行的操作与其紧密关联。您甚至可以嵌套多个上下文操作,而不必担心析构函数顺序。
您甚至可以进一步扩展,并创建一个通用的WithContext类,它需要额外的设置和拆除函数参数。
编辑:必须将action()调用移动到单独的Execute函数中以恢复异常安全性 - 如果在构造函数中调用并引发异常,则析构函数将无法被调用。
编辑2:通用技术 -
所以我更深入地探索了这个想法,并想出了更好的解决方案:
我将定义一个With类,它将创建上下文变量并将其放入其初始化程序中的std::auto_ptr中,然后调用action:
template <typename T>
class With
{
public:
    template <typename Func>
    With(Func action) : context(new T()) 
    { action(); }

    template <typename Func, typename Arg>
    With(Arg arg, Func action) : context(new T(arg))
    { action(); }

private:
    const std::auto_ptr<T> context;
};

现在,您可以将其与您最初定义的上下文类型相结合:
struct PushedMatrix 
{
    PushedMatrix() { glPushMatrix(); }
    ~PushedMatrix() { glPopMatrix(); }
};

并且像这样使用它:

With<PushedMatrix>([]
{
    glBegin(GL_LINES);
    //etc. etc.
});

或者

With<EnabledFlag>(GL_BLEND, []
{
    //...
});

好处:

  1. 现在由auto_ptr处理异常安全性,因此,如果action抛出异常,上下文仍将得到正确销毁。
  2. 不再需要Execute方法,所以它看起来更加清晰! :)
  3. 您的“上下文”类非常简单;所有逻辑都由With类处理,因此您只需要为每个新类型的上下文定义一个简单的构造函数/析构函数。

一个小问题:如上所述,您需要为需要的参数声明手动重载的构造函数;尽管即使只有一个参数也应该涵盖大多数OpenGL用例,但这并不真正好看。 这应该通过可变模板很好地解决-只需在构造函数中用typename ...Args替换typename Arg,但这将取决于编译器对其的支持(MSVC2010尚未支持)。


2
+1,这样语义上更好,因为“withPushedMatrix”内的代码确实是“withPushedMatrix”,看起来不像一个hack;我想我会在自己的OpenGL应用中使用它:p - Tomaka17
2
等等,有一个问题:如果lambda内部发生异常,~WithPushedMatrix的析构函数将不会被调用;事实上,在“action()”周围加上try-catch的简单函数会更好。 - Tomaka17
@Tomaka, Alexandre:异常安全已恢复!虽然有点丑,但这样就不需要显式地使用try-catch了。 - tzaman
2
-1:当我在lambda块中时,如何从封闭函数返回?这种解决方案会阻碍控制流,我无法跳出该块。不太好。 - Nordic Mainframe
1
我认为@Luther的观点很好。您将无法从lambda块内部中断、返回、继续等,影响其封闭函数中的任何代码。 - Johannes Schaub - litb
显示剩余8条评论

22
使用这样的对象被称为RAII,在资源管理中非常典型。是的,有时您会因为忘记提供变量名而过早地销毁临时对象。但这里有一个巨大的优势-代码变得更加安全,并且更干净整洁-您不必在所有可能的代码路径上手动调用所有清理操作。

建议:使用合理的变量名称,而不是p。将其命名为matrixSwitcher或类似的内容,以便读者不认为这是一个无用的变量。


如果您想提高StackOverflow上答案的Google排名,那么应该链接到该答案而不是维基百科。 ;) - wilhelmtell
5
这个回答在4小时内如何获得21个赞?这个答案像几乎所有其他答案一样,指出了RAII模式,并且除此之外重复了OP已经知道的内容。最后以模糊的建议结束,建议用有意义的方式命名变量。这充其量是一个平庸的回答,如果这意味着200点声望,那么SO声望系统就严重有问题。我曾经在SO上看到过聪明、周到和复杂的答案,但得到的赞数不到+3,而这个回答却能获得+21? - Nordic Mainframe
简洁明了是一种美德。 - Dennis Zickefoose
1
就我而言,我认为“资源”在这里是一个上下文,可以在其中完成某些工作,这个观点是有一定的合理性的......这不是值得大辩论的问题。另外,“作用域保护”是另一个相关的术语(例如,Alexandrescu在大约十年前提出的通用解决方案)。我更关心类型名称传达实用性(例如以Guard结尾),而不是希望客户端代码创建有意义的变量名... - Tony Delroy
@TonyD:好吧,我不是因为OpenGL设计得很差或解决方案很差而投反对票;我之所以投反对票,是因为这不是“RAII”,也不是“非常典型”的“资源管理”,正是因为这并不是真正的资源管理。答案中的这部分内容就是错的。如果它说得更合理一些,比如“作用域保护器”,那么它就比RAII好多了。 - user541686
显示剩余12条评论

6

正如其他人所指出的那样,这是C++中一种众所周知并且被鼓励的模式。

解决忘记变量名称问题的方法是定义操作需要该变量。可以通过将可能的操作作为RAII类的成员来实现:

PushedMatrix pushed_matrix;;
pushed_matrix.transform( /*...*/ );

或者通过使函数将RAII类作为参数:

PushedMatrix pushed_matrix;
transform_matrix( pushed_matrix, /*...*/ );

5
我想指出的是,我的答案实际上包含了有用的信息(不仅仅是模糊地提到 RAII,而这一点显然值得19个赞)。它不需要 c++0x 就可以工作,也不是假设性的,可以解决与声明变量相关的 OP 问题。
有一种非常好的方法可以在语法上增强 RAII 构造(或者更准确地说是 ScopeGuards):if() 语句接受在 if 块中作用域的声明。
#include <stdio.h>

class Lock
{
    public:
    Lock() { printf("locking\n"); }
    ~Lock() { printf("unlocking\n"); }
    operator bool () const { return true;}
};
int main()
{
    // id__ is valid in the if-block only
    if (Lock id_=Lock()) {  
        printf("..action\n");
    }
}

这会打印出:
locking
..action
unlocking

如果我们添加一些语法糖,我们可以这样写:
#define WITH(X) if (X with_id_=X())
int main()
{
    WITH(Lock) {    
        printf("..action\n");
        WITH(Lock) {
            printf("more action\n");
        }
    }
}

现在我们利用这个事实:用于初始化const引用的临时变量只要const引用在作用域内,它们就会一直存在,来使其与参数配合使用(我们还修复了WITH(X)接受尾随else的问题):

   #include <stdio.h>
   class ScopeGuard 
   {
    public:
    mutable int dummy;
    operator bool () const { return false;}
    ScopeGuard(){}
    private:
    ScopeGuard(const ScopeGuard &); 
    }; 
    class Lock : public ScopeGuard
    {
        const char *s;
        public: 
        Lock(const char *s_) : s(s_) { printf("locking %s\n",s); }
        ~Lock() { printf("unlocking %s\n",s); }
    };

    #define WITH(X) if (const ScopeGuard& with_id_=X)  {} else 
    int main()
    {
        WITH(Lock("door")) {    
            printf("..action\n");
            WITH(Lock("gate")) {
                printf("more action\n");
            }
        }
    }

TATA!

这种方法的一个好处是,所有的“保护”区域都可以通过WITH(...) {...}模式统一地识别,这对于代码审查等方面是一个不错的特性。


1
更具体地说,任何带有双下划线的名称都是保留的。 - Dennis Zickefoose
1
基本上,这意味着如果可能会调用复制构造函数,那么您必须假定它将被调用。如果没有这个条款,在调试构建中,Class Function() { return Class(); } 可能不符合规范,但在启用优化的发布版本中则符合规范。 - Dennis Zickefoose
1
但这也意味着,如果在某个上下文中无法调用复制构造函数,则编译器应该引发错误,无论是否使用或优化掉复制构造函数。这在GCC中没有发生(ScopeGuard的复制构造函数是私有的)。如果Johannes的论点成立,那么这意味着GCC存在缺陷,不是吗? - Nordic Mainframe
这种方法存在一个问题,即调用代码能够在 WITH(x) {} 结构之后使用 else {} 结构。我建议改用 for() {} 结构。它还有一个好处,就是不需要 bool 转换运算符 #define WITH(type) for (type const & WITH_##type##_instance = type(), * WITH_##type##_ptr = &WITH_##type##_instance; WITH_##type##_ptr != 0; WITH_##type##_ptr = 0) - Nick Strupat
任何像样的优化器都应该优化指针并展开循环。 - Nick Strupat
显示剩余9条评论

4

警告:本答案基于C++0x

你正在使用的模式是RAII,它被广泛用于资源管理。唯一可能的替代方案是使用try-catch块,但这通常会使你的代码变得有点混乱。

现在来看问题。 首先,如果你不想为每个OpenGL函数组合编写不同的类,那么C++0x的另一个好处是可以编写lambda函数并将其存储在变量中。所以如果我是你,我会创建一个像这样的类:

template<typename Destr>
class MyCustom {
public:
    template<typename T>
    MyCustom(T onBuild, Destr onDestroy) : 
        _onDestroy(std::move(onDestroy))
    {
        onBuild();
    }

    ~MyCustom() { _onDestroy(); }

private:
    Destr    _onDestroy;
};

template<typename T1, typename T2>
MyCustom<T2> buildCustom(T1 build, T2 destruct)   { return MyCustom<T2>(std::move(build), std::move(destruct)); }

然后你可以像这样使用它:
auto matrixPushed = buildCustom([]() { glPushMatrix(); }, []() { glPopMatrix(); });

今日免费次数已满, 请开通会员/明日再来
auto matrixPushed = buildCustom(&glPushMatrix, &glPopMatrix);

这样做也解决了“为什么这个无用的变量在这里”的问题,因为它现在的目的变得明显了。
传递给构造函数的函数应该是内联的,因此没有性能开销。析构函数应该像函数指针一样存储,因为没有括号[]的lambda函数应该像普通函数一样实现(根据标准)。
使用“buildCustom”也可以部分解决“变量立即销毁”的问题,因为您可以更容易地看到忘记变量的位置。

3
为了帮助你理解C++程序员已经使用这种技术多长时间,我在90年代后期与COM一起工作时学习了这种技术。
我认为选择使用确切的机制来利用C++堆栈框架和析构函数的基本属性以使对象的生命周期管理更加容易是个人选择。我不会费太大力气去避免需要分配变量。
(下面这件事我不确定是否正确,但我还是写上了,希望有人能提供帮助 - 我知道我过去做过这件事,但现在在谷歌上找不到它,我的思维被垃圾收集器弄迷糊了!)
我相信您可以通过一对简单的花括号(POPOC)来强制执行范围。
{ // new stack frame
  auto_ptr<C> instanceA(new C);
  {
     auto_ptr<C> instanceB(new C);
  }
  // instanceB is gone
} 
// instanceA is gone

1

1

ScopeGuard 这个类很适合。请注意,使用 C++0x 的 bind 和可变参数模板可以将其重写得更短。


0

我认为这是很棒的、符合惯用法的C++。缺点是你基本上要在C OpenGL库周围编写一个(自定义)包装器。如果存在这样的库,比如(半)官方的OpenGL++库,那就太好了。

话虽如此,我已经写过类似于这样(从记忆中),并且非常满意:

{
  Lighting light = Light(Color(128,128,128));
    light.pos(0.0, 1.0, 1.0);
  Texture tex1 = Texture(GL_TEXTURE1);
    tex1.set(Image("CoolTex.png"));

  drawObject();
}

编写包装器的开销并不太大,生成的代码与手写代码一样好。即使您没有将包装器烂熟于心,它也比对应的OpenGL代码容易阅读(个人意见)。

0

我以前从未见过这个,但我必须承认,它有点酷。

但我不会使用它,因为它并不是很直观。

编辑:正如 sharptooth 指出的那样,这被称为 RAII。我在维基百科上找到的示例还将资源操作封装在方法调用中。在你的示例中,这将如下所示:

WithPushedMatrix p;
p.setFLag(GL_BLEND);
p.doSomething();

那么变量的作用就很清晰了,其他开发者阅读你的代码时也会有直觉。当然,OpenGL 代码就被隐藏起来了,但我认为人们会很快习惯这种方式。


2
在处理锁、打开/关闭文件、内存分配等情况时,通常会使用RAII技术。 - Alexandre C.

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接