不使用const_cast修改*this的常量方法

9
我正在编写一个程序,发现以下模式。希望它不是太牵强,但它成功地在常量方法Foo :: Questionable() const中改变了Foo对象,而没有使用任何const_cast或类似的东西。基本上,Foo存储对FooOwner的引用,反之亦然,在Questionable()中,Foo通过调用其所有者的mutate_foo()方法,在常量方法中成功修改自身。下面是一些问题。
#include "stdafx.h"
#include <iostream>
using namespace std;

class FooOwner;

class Foo {
    FooOwner& owner;
    int data;

public:
    Foo(FooOwner& owner_, int data_)
        : owner(owner_),
          data(data_)
    {
    }

    void SetData(int data_)
    {
        data = data_;
    }

    int Questionable() const;       // defined after FooOwner
};

class FooOwner {
    Foo* pFoo;

public:
    FooOwner()
        : pFoo(NULL)
    {}

    void own(Foo& foo)
    {
        pFoo = &foo;
    }

    void mutate_foo()
    {
        if (pFoo != NULL)
            pFoo->SetData(0);
    }
};

int Foo::Questionable() const
{
    owner.mutate_foo();     // point of interest
    return data;
}

int main()
{
    FooOwner foo_owner;
    Foo foo(foo_owner, 0);      // foo keeps reference to foo_owner
    foo_owner.own(foo);         // foo_owner keeps pointer to foo

    cout << foo.Questionable() << endl;  // correct?

    return 0;
}

这算是定义良好的行为吗?Foo::data 应该声明为可变的吗?还是这意味着我正在以致命方式处理问题?我试图实现一种懒加载的“数据”,只有在请求时才设置,以下代码编译没有警告,所以我有点担心我处于未定义行为领域。
编辑:Questionable() 上的 const 只使直接成员 const,并不使对象指向或引用的对象 const。这样做是否合法?我对 Questionable()this 的类型为 const Foo*,而在调用堆栈的后面,FooOwner 合法地具有非 const 指针来修改 Foo 感到困惑。这是否意味着可以修改 Foo 对象?
编辑2:也许一个更简单的例子:
class X {
    X* nonconst_this;   // Only turns in to X* const in a const method!
    int data;

public:
    X()
        : nonconst_this(this),
          data(0)
    {
    }

    int GetData() const
    {
        nonconst_this->data = 5;    // legal??
        return data;
    }
};

Foo或FooOwner对象都没有被声明为const。尝试将它们声明为const,你会看到编译器的反应。如果有一个名为SetData的公共方法,为什么要声明Foo::data为可变的? - celavek
FooOwner foo_owner不能被定义为const,因为Foo(FooOwner&owner ...)期望非const引用。正如Marius所建议的,只需尝试一下。一切都很好。该方法是const的,但它知道(正确地)修改的数据不是const的,并且可以被修改。 - Suma
1
你的新示例更简单,但实际上更有趣。这样,你甚至可以修改一个const对象而不需要任何转换,利用对象在构造函数中永远不可能是const的事实 - 在那里捕获指针实际上与const cast相同,你很容易就会遇到未定义行为。const X x; x.GetData(); // 未定义行为 - Suma
5个回答

29
考虑以下内容:
int i = 3;

i是一个对象,它的类型为int。它没有被限定(不是const或者volatile,或者两者都不是)。

现在我们添加:

const int& j = i;
const int* k = &i;

j是指向i的引用,而k是指向i的指针。(从现在开始,我们将“引用”和“指向”简称为“指向”。)

此时,我们有两个cv-qualified变量jk,它们指向一个非cv-qualified对象。这在§7.1.5.1/3中提到:

指向或引用cv-qualified类型的指针或引用不一定实际指向或引用cv-qualified对象,但它被视为实际指向或引用;即使所引用的对象是非const对象并且可以通过其他访问路径进行修改,也不能使用const限定的访问路径来修改对象。[注意:类型系统支持cv-qualifier以便它们不能被转换(5.2.11)]

这意味着编译器必须尊重jk是cv-qualified的,即使它们指向一个非cv-qualified对象。(因此j = 5*k = 5是不合法的,即使i = 5是合法的。)

现在考虑将其移除const

const_cast<int&>(j) = 5;
*const_cast<int*>(k) = 5;

这是合法的(§参见5.2.11),但它是否未定义行为?不是。请参见§7.1.5.1/4:

除了任何声明为mutable(7.1.1)的类成员可以被修改,在其生命周期(3.8)内尝试修改const对象会导致未定义的行为强调我的。

请记住,i不是const,而jk都指向i。我们所做的只是告诉类型系统从类型中删除const限定符,以便我们可以修改指向的对象,然后通过这些变量修改i

这与执行以下操作完全相同:

int& j = i; // removed const with const_cast...
int* k = &i; // ..trivially legal code

j = 5;
*k = 5;

而这个过程是非常合法的。我们现在考虑 i 改为如下:

const int i = 3;

我们现在的代码怎么样了?
const_cast<int&>(j) = 5;
*const_cast<int*>(k) = 5;

现在会导致未定义的行为,因为i是一个带有const限定词的对象。我们告诉类型系统删除const以便可以修改指向的对象,然后修改了一个带有const限定词的对象。如上所述,这是未定义的。

再次强调,更明显的是:

int& j = i; // removed const with const_cast...
int* k = &i; // ...but this is not legal!

j = 5;
*k = 5;

请注意,仅仅这样做是不够的:
const_cast<int&>(j);
*const_cast<int*>(k);

这是完全合法和明确定义的,因为没有修改const-限定的对象;我们只是在玩弄类型系统。


现在考虑:

struct foo
{
    foo() :
    me(this), self(*this), i(3)
    {}

    void bar() const
    {
        me->i = 5;
        self.i = 5;
    }

    foo* me;
    foo& self;
    int i;
};

bar中的const对成员变量有什么影响?它使得访问成员变量必须通过一条称为cv-qualified access path的路径。(它通过将this的类型从T* const更改为cv T const*来实现,其中cv是函数的cv限定符。)

那么在执行bar期间成员变量的类型是什么?它们是:

// const-pointer-to-non-const, where the pointer points cannot be changed
foo* const me;

// foo& const is ill-formed, cv-qualifiers do nothing to reference types
foo& self; 

// same as const int
int const i; 

当然,指针类型并不重要,重要的是指向对象的 const 限定,而不是指针本身。如果上面的 k 是 "const int* const",那么后面的 const 就无关紧要了。现在我们考虑:
int main()
{
    foo f;
    f.bar(); // UB?
}

bar 中,meself 都指向一个非常量的 foo,所以和上面的 int i 一样,我们有明确定义的行为。如果我们有:

const foo f;
f.bar(); // UB!

我们会遇到UB,就像使用const int一样,因为我们要修改一个带有const修饰的对象。

在你的问题中,没有带有const修饰的对象,所以没有未定义的行为。


为了补充权威性,考虑Scott Meyers的const_cast技巧,用于在非const函数中重复使用一个带有const修饰的函数:

struct foo
{
    const int& bar() const
    {
        int* result = /* complicated process to get the resulting int */
        return *result; 
    }

    int& bar()
    {
        // we wouldn't like to copy-paste a complicated process, what can we do?
    }

};

他建议:
int& bar(void)
{
    const foo& self = *this; // add const
    const int& result = self.bar(); // call const version
    return const_cast<int&>(result); // take off const
}

或者通常的写法:

int& bar(void)
{
    return const_cast<int&>( // (3) remove const from result
            static_cast<const foo&>(*this) // (1) add const to this
            .bar() // (2) call const version
            ); 
}

请注意,这是完全合法和明确定义的。具体来说,因为必须在非const限定的foo上调用此函数,所以我们可以完全安全地从int& boo() const的返回类型中去除const限定。
(除非有人一开始就使用const_cast + 调用自己。)
总结一下:
struct foo
{
    foo(void) :
    i(),
    self(*this), me(this),
    self_2(*this), me_2(this)
    {}

    const int& bar() const
    {
        return i; // always well-formed, always defined
    }

    int& bar() const
    {
        // always well-formed, always well-defined
        return const_cast<int&>(
                static_cast<const foo&>(*this).
                bar()
                );
    }

    void baz() const
    {
        // always ill-formed, i is a const int in baz
        i = 5; 

        // always ill-formed, me is a foo* const in baz
        me = 0;

        // always ill-formed, me_2 is a const foo* const in baz
        me_2 = 0; 

        // always well-formed, defined if the foo pointed to is non-const
        self.i = 5;
        me->i = 5; 

        // always ill-formed, type points to a const (though the object it 
        // points to may or may not necessarily be const-qualified)
        self_2.i = 5; 
        me_2->i = 5; 

        // always well-formed, always defined, nothing being modified
        // (note: if the result/member was not an int and was a user-defined 
        // type, if it had its copy-constructor and/or operator= parameter 
        // as T& instead of const T&, like auto_ptr for example, this would 
        // be defined if the foo self_2/me_2 points to was non-const
        int r = const_cast<foo&>(self_2).i;
        r = const_cast<foo* const>(me_2)->i;

        // always well-formed, always defined, nothing being modified.
        // (same idea behind the non-const bar, only const qualifications
        // are being changed, not any objects.)
        const_cast<foo&>(self_2);
        const_cast<foo* const>(me_2);

        // always well-formed, defined if the foo pointed to is non-const
        // (note, equivalent to using self and me)
        const_cast<foo&>(self_2).i = 5;
        const_cast<foo* const>(me_2)->i = 5;

        // always well-formed, defined if the foo pointed to is non-const
        const_cast<foo&>(*this).i = 5;
        const_cast<foo* const>(this)->i = 5;
    }

    int i;

    foo& self;
    foo* me;
    const foo& self_2;
    const foo* me_2;
};

int main()
{
    int i = 0;
    {
        // always well-formed, always defined
        int& x = i;
        int* y = &i;
        const int& z = i;
        const int* w = &i;

        // always well-formed, always defined
        // (note, same as using x and y)
        const_cast<int&>(z) = 5;
        const_cast<int*>(w) = 5;
    }

    const int j = 0;
    {
        // never well-formed, strips cv-qualifications without a cast
        int& x = j;
        int* y = &j;

        // always well-formed, always defined
        const int& z = i;
        const int* w = &i;

        // always well-formed, never defined
        // (note, same as using x and y, but those were ill-formed)
        const_cast<int&>(z) = 5;
        const_cast<int*>(w) = 5;
    }

    foo x;
    x.bar(); // calls non-const, well-formed, always defined
    x.bar() = 5; // calls non-const, which calls const, removes const from
                 // result, and modifies which is defined because the object
                 // pointed to by the returned reference is non-const,
                 // because x is non-const.

    x.baz(); // well-formed, always defined

    const foo y;
    y.bar(); // calls const, well-formed, always defined
    const_cast<foo&>(y).bar(); // calls non-const, well-formed, 
                               // always defined (nothing being modified)
    const_cast<foo&>(y).bar() = 5; // calls non-const, which calls const,
                                   // removes const from result, and
                                   // modifies which is undefined because 
                                   // the object pointed to by the returned
                                   // reference is const, because y is const.

    y.baz(); // well-formed, always undefined
}

我参考的是 ISO C++03 标准。


2
这可能是我读过的最好的stackoverflow答案。清晰,讲解得很好,并且正好回答了我想要的问题。谢谢,希望您能得到应有的声誉:) - AshleysBrain

6

在我看来,您在技术上并没有做错任何事情。也许如果成员是一个指针,那么就会更容易理解。

class X
{
    Y* m_ptr;
    void foo() const {
        m_ptr = NULL; //illegal
        *m_ptr = 42; //legal
    }
};

const关键字使得指针(pointer)成为常量,而不是指向的对象(pointee)

请考虑以下两者之间的区别:

const X* ptr;
X* const ptr;  //this is what happens in const member functions

关于引用,由于它们无法重新分配,因此方法上的const关键字对引用成员没有任何影响。

在您的示例中,我没有看到任何const对象,因此您没有做错什么,只是利用了C++中const正确性工作方式中的奇怪漏洞。


如果你是正确的,那么我想你可以通过在类中拥有一个T* nonconst_this成员,将其初始化为this,然后在任何const方法中随意修改对象来规避任何const方法的常量性?这将是一个相当大的const漏洞! - AshleysBrain
@UncleBens:你对于const限定的成员函数是错误的。请参考标准(请看我的回答中相关的引用)。 - dirkgently
@Dead:它错在哪里?让我们澄清一些术语,这样我们就不会争论语义了。这是一个常量指针:T* const,这是一个指向常量的指针:const T*,这是一个常量指针到常量:const T* const。同意吗?引用不遵循这个主题,因为这两个都是不合法的T& constconst T& const。(如果我们遵循这个主题,它们将成为常量引用和常量引用到常量。)所以当有人说常量引用时,知道他们不可能意味着T & const,我们可以推断他们的意思是const T&。事实上,这是事实上... - GManNickG
1
...使用const引用。因此,当你说“如果你有一个const方法,那么引用就变成了const引用。”时,如果你的意思是T&变成了T& const,那么你是错误的;这种转换会使程序不合法。而且,T&也不会变成const T&(const引用的事实上的用法);确实如此,T&不会变成const T&。因此,你整个句子要么没有意义,要么是错误的。@Uncle是正确的,因为T& const是没有意义的,引用实际上不受cv限定符的影响。 - GManNickG
@AshleysBrain:请把我的回答视为 Uncle 的延伸。 - GManNickG
显示剩余7条评论

1

不管它是否被允许/应该被允许/可以被允许,我强烈建议不要这样做。语言中有机制可以实现你想要的效果,而不需要编写晦涩难懂的结构,这很可能会使其他开发人员感到困惑。

请查看mutable关键字。该关键字可用于声明成员,在const成员方法中可以修改它们,因为它们不会影响类的可感知状态。考虑一个使用一组参数初始化并执行复杂昂贵计算的类,这些计算可能并不总是需要:

class ComplexProcessor
{
public:
   void setInputs( int a, int b );
   int getValue() const;
private:
   int complexCalculation( int a, int b );
   int result;
};

一种可能的实现方式是将结果值作为成员添加,并为每个集合计算它:
void ComplexProcessor::setInputs( int a, int b ) {
   result = complexCalculation( a, b );
}

但这意味着该值在所有集合中都被计算,无论是否需要。如果您将对象视为黑盒子,则接口仅定义了设置参数的方法和检索计算值的方法。计算执行的时间并不真正影响对象的感知状态——只要getter返回的值是正确的。因此,我们可以修改类以存储输入(而不是输出),并仅在需要时计算结果:

class ComplexProcessor2 {
public:
   void setInputs( int a, int b ) {
      a_ = a; b_ = b;
   }
   int getValue() const {
      return complexCalculation( a_, b_ );
   }
private:
   int complexCalculation( int a, int b );
   int a_,b_;
};

语义上第二类和第一类是等价的,但现在我们避免了进行复杂计算,如果值不需要,则这是一个优势,因此仅在某些情况下请求值时是有利的。但同时,如果对于同一对象多次请求值,则是一个劣势:每次执行复杂计算,即使输入没有改变。
解决方案是缓存结果。为此,我们可以将结果存储到类中。当请求结果时,如果我们已经计算过它,我们只需要检索它,而如果我们没有该值,我们必须计算它。当输入改变时,我们使缓存无效。这就是 mutable 关键字派上用场的时候。它告诉编译器该成员不是可感知状态的一部分,因此它可以在常量方法内被修改。
class ComplexProcessor3 {
public:
   ComplexProcessor3() : cached_(false) {}
   void setInputs( int a, int b ) {
      a_ = a; b_ = b;
      cached_ = false;
   }
   int getValue() const {
      if ( !cached_ ) {
         result_ = complexCalculation( a_, b_ );
         cached_ = true;
      }
      return result_;
   }
private:
   int complexCalculation( int a, int b );
   int a_,b_;
   // This are not part of the perceivable state:
   mutable int result_;
   mutable bool cached_;
};

第三种实现在语义上等效于前两个版本,但如果结果已知且已被缓存,则避免重新计算该值。在其他地方,例如多线程应用程序中,类中的互斥体通常标记为“mutable”才需要使用“mutable”关键字。锁定和解锁互斥量是互斥量的变异操作:它的状态明显正在改变。现在,在在不同线程之间共享的对象中的getter方法不会修改感知状态,但如果操作必须是线程安全的,则必须获取并释放锁定。
template <typename T>
class SharedValue {
public:
   void set( T v ) {
      scoped_lock lock(mutex_);
      value = v;
   }
   T get() const {
      scoped_lock lock(mutex_);
      return value;
   }
private:
   T value;
   mutable mutex mutex_;
};

即使需要修改互斥锁以确保单线程访问value成员变量,getter操作在语义上仍然是常数操作。


0

const关键字仅在编译时检查时考虑。C++没有提供任何保护类免受任何内存访问的设施,这就是您使用指针/引用进行的操作。编译器和运行时都无法知道您的指针是否指向您在某个地方声明为const的实例。

编辑:

简短示例(可能无法编译):

// lets say foo has a member const int Foo::datalength() const {...}
// and a read only acces method const char data(int idx) const {...}

for (int i; i < foo.datalength(); ++i)
{
     foo.questionable();  // this will most likely mess up foo.datalength !!
     std::cout << foo.data(i); // HERE BE DRAGONS
}

在这种情况下,编译器可能会决定,嘿,foo.datalength是const的, 并且循环内的代码承诺不会更改foo,因此我必须在进入循环时仅评估 datalength一次。太好了! 如果您尝试调试此错误,最有可能只会在使用优化编译(而不是调试构建)时出现,您将会变得疯狂。
遵守承诺!或者在高度警觉的情况下使用可变!

不正确,const不能用于编译器优化,因为可以强制转换掉const,除非所指向的数据确实被定义为const(这是非常不典型和罕见的情况,因为这意味着静态初始化的const数据或const数据成员,需要在构造函数中初始化并且永远不会再次更改)。 - Suma
强制转换去除const是编译器知道的事情。 - AndreasT

-1

您已经遇到了循环依赖。请参见FAQ 39.11,即使您绕过编译器,修改const数据也是UB。此外,如果您不遵守承诺(即违反const),则会严重影响编译器的优化能力。

如果您知道将通过调用其所有者来修改Questionable,为什么还要将其定义为const?拥有对象需要知道所有者吗?如果您确实需要这样做,那么mutable就是正确的选择。这就是它存在的原因-逻辑上的常量性(而不是严格的位级常量性)。

从我的草案n3090中得知:

9.3.2 this指针 [class.this]
1 在非静态(9.3)成员函数的函数体中,关键字this是一个rvalue a prvalue表达式,其值是调用该函数的对象的地址。类X的成员函数中的this类型为X*。如果成员函数被声明为const,则this的类型为const X*;如果成员函数被声明为volatile,则this的类型为volatile X*;如果成员函数被声明为const volatile,则this的类型为const volatile X*。
2 在const成员函数中,通过const访问路径访问调用函数的对象;因此,const成员函数不得修改对象及其非静态数据成员。
[注意强调是我的]。
关于UB:
7.1.6.1 cv-qualifiers
3 指向或引用cv限定类型的指针或引用不一定实际指向或引用cv限定对象,但它被视为实际指向或引用;即使所引用的对象是非const对象并且可以通过其他访问路径进行修改,也不能使用const限定的访问路径来修改对象。注意:类型系统支持cv限定符,以便它们不能被转换而绕过(5.2.11)。-注释
4 除了任何声明为mutable(7.1.1)的类成员可以被修改之外,在其生命周期(3.8)内尝试修改const对象的任何尝试都会导致未定义的行为。

1
我在其他地方读到过,const并不用于优化,原因是const_cast在其他地方被合法使用。此外,方法上的const仅适用于直接成员,而不适用于指向或引用的对象。这是否使得对mutate_foo()的调用合法? - AshleysBrain
不,这并不使调用合法。你“承诺”不改变对象。当编译器需要决定一段代码是否具有副作用时,这非常重要,更重要的是,它可以帮助你检查错误!你正在自掘坟墓。与我们分享对类型系统的爱恨情感,并遵守规则,它可能会成为你的朋友 :) - AndreasT
1
我给你点负分是因为你错误地诊断了UB。只有当对象是“const”时,修改对象才是UB。例如:int i = 5; const int& j = i; const_cast<int&>(j) = 7;int i = 5; const int* j = &i; *const_cast<int*>(j) = 7;。这两个都是100%合法的,不会导致任何未定义的行为。但是,如果“i”被声明为“const int”,那么这两个都将是未定义的行为。现在观察问题中的程序:哪里有“const”对象?指出“this”将变为“const T* const”与指出上面的“j”变为“const int&”一样没有意义:两者... - GManNickG
1
请注意最后一部分:“任何试图修改常量对象的尝试...都会导致未定义的行为。” 常量对象与引用对象的cv限定类型不同,就像引语的第一部分所说的那样(“cv限定类型实际上不需要指向或引用cv限定对象”)。 我给出的示例证明了这一点:j是指向非cv限定对象的cv限定类型。 您可以从类型中删除cv限定符并修改对象。 仅当原始对象具有cv限定符(例如如果它是const int i = 5)时,才会删除cv限定符... - GManNickG
1
@dirk:指针,即mutate_foo使用的访问路径是非const的。因此,尽管某些调用者的堆栈帧中存在对该对象的const指针,但它仍然可以用于更改不是const的对象。在函数A中简单形成一个const指针不会改变程序其余部分的语义。如果对象本身是const,则无法形成非const路径而不使用const_cast,利用这样的路径将产生UB。 - Potatoswatter
显示剩余16条评论

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