“ this”指针只是编译时的东西吗?

时间:2018-11-12 15:01:37

标签: c++ gcc this this-pointer

我问自己,this指针是否会被过度使用,因为通常我每次引用成员变量或函数时都会使用它。我想知道它是否会对性能产生影响,因为必须有一个每次都需要取消引用的指针。所以我写了一些测试代码

struct A {
    int x;

    A(int X) {
        x = X; /* And a second time with this->x = X; */
    }
};

int main() {
    A a(8);

    return 0;
}

令人惊讶的是,即使使用-O0,它们也输出完全相同的汇编代码。

如果我使用一个成员函数并在另一个成员函数中调用它,它也会显示相同的行为。那么this指针只是编译时的东西,而不是实际的指针吗?还是在某些情况下实际上this已被翻译和取消引用?我使用GCC 4.4.3 btw。

12 个答案:

答案 0 :(得分:81)

  

那么this指针只是编译时的东西,而不是实际的指针吗?

很多时候 是运行时的东西。它指的是在其上调用成员函数的对象,该对象自然可以在运行时存在。

什么是 编译时的事情是名称查找的工作方式。当编译器遇到x = X时,它必须找出正在分配的x是什么。因此它进行查找,并找到成员变量。由于this->xx指的是同一件事,因此自然会得到相同的程序集输出。

答案 1 :(得分:28)

这是一个实际的指针,如标准所指定(第12.2.2.1节):

  

在非静态(12.2.1)成员函数的主体中,关键字this是prvalue表达式,其值是为其调用该函数的对象的地址。类this的成员函数中X的类型为X*

每次您在类自己的代码中引用非静态成员变量或成员函数时,

this实际上是隐式的。它也是必需的(无论是隐式还是显式的),因为编译器需要在运行时将函数或变量绑定到实际对象。

显式使用它很少有用,除非您需要例如在成员函数中的参数和成员变量之间消除歧义。否则,没有它,编译器将用参数(See it live on Coliru)遮盖成员变量。

答案 2 :(得分:17)

使用非静态方法时,

data必须始终存在。无论是否显式使用它,都必须引用当前实例,这就是data所提供的。

在两种情况下,您都将通过this指针访问内存。只是在某些情况下您可以忽略它。

答案 3 :(得分:16)

这几乎是How do objects work in x86 at the assembly level?的副本,在这里我评论了一些示例的asm输出,包括显示传入了this指针的寄存器。


在asm中,this的工作方式与隐藏的第一个arg 完全相同,因此成员函数foo::add(int)和非成员add都需要一个 explicit foo*首先将arg编译为完全相同的asm。

struct foo {
    int m;
    void add(int a);  // not inline so we get a stand-alone definition emitted
};

void foo::add(int a) {
    this->m += a;
}

void add(foo *obj, int a) {
    obj->m += a;
}

On the Godbolt compiler explorer,使用System V ABI进行x86-64编译(RDI中的第一个arg,RSI中的第二个arg),我们得到:

# gcc8.2 -O3
foo::add(int):
        add     DWORD PTR [rdi], esi   # memory-destination add
        ret
add(foo*, int):
        add     DWORD PTR [rdi], esi
        ret

  

我使用GCC 4.4.3

那是released in January 2010,因此它缺少对优化器和错误消息的近十年的改进。 gcc7系列已经停产了一段时间了。期望使用这样的旧编译器错过优化,尤其是对于像AVX这样的现代指令集。

答案 4 :(得分:10)

编译后,每个符号都只是一个地址,因此它不是运行时问题。

即使您没有使用this,任何成员符号都将被编译为当前类中的偏移量。

在C ++中使用name时,它可以是下列之一。

  • 在全局名称空间(如::name),当前名称空间或已使用的名称空间(使用using namespace ...时)
  • 在当前课程中
  • 局部定义,在上部
  • 本地定义,在当前块中

因此,在编写代码时,编译器应从当前块一直到全局名称空间对每个符号进行扫描,以查找符号名称。

使用this->name有助于编译器缩小对name的搜索范围,使其仅在当前类范围内查找,这意味着它会跳过局部定义,并且如果在类范围内未找到,则不会查找在全球范围内。

答案 5 :(得分:7)

这是一个简单的示例,在运行时如何使用“ this”:

#include <vector>
#include <string>
#include <iostream>

class A;
typedef std::vector<A*> News; 
class A
{
public:
    A(const char* n): name(n){}
    std::string name;
    void subscribe(News& n)
    {
       n.push_back(this);
    }
};

int main()
{
    A a1("Alex"), a2("Bob"), a3("Chris");
    News news;

    a1.subscribe(news);
    a3.subscribe(news);

    std::cout << "Subscriber:";
    for(auto& a: news)
    {
      std::cout << " " << a->name;
    }
    return 0;
}

答案 6 :(得分:7)

您的机器对类方法一无所知,它们是幕后的正常功能。 因此,必须始终通过将指针传递给当前对象来实现方法,这在C ++中是隐式的,即T Class::method(...)只是T Class_Method(Class* this, ...)的语法糖。

其他语言(如Python或Lua)选择使其显式,而现代的面向对象的C API(如Vulkan(与OpenGL不同))也使用类似的模式。

答案 7 :(得分:5)

  

因为我通常每次都引用成员变量或函数时才使用它。

在引用成员变量或函数时,您始终使用this。根本没有其他联系会员的方法。唯一的选择是隐式与显式符号。

让我们回头看看在this之前是如何完成的,以了解this是什么。

没有OOP:

struct A {
    int x;
};

void foo(A* that) {
    bar(that->x)
}

使用OOP,但显式编写this

struct A {
    int x;

    void foo(void) {
        bar(this->x)
    }
};

使用较短的符号:

struct A {
    int x;

    void foo(void) {
        bar(x)
    }
};

但是区别仅在于源代码。全部都编译为同一件事。如果创建成员方法,则编译器将为您创建一个指针参数并将其命名为“ this”。如果在引用成员时省略this->,则编译器很聪明,足以在大多数时候为您插入。而已。唯一的区别是源代码中少了6个字母。

在存在歧义的情况下明确地写this是有意义的,也就是说,另一个变量的名称与您的成员变量一样:

struct A {
    int x;

    A(int x) {
        this->x = x
    }
};

在__thiscall之类的某些实例中,OO和非OO代码在asm中的结尾可能有所不同,但是每当指针在堆栈上传递然后从一开始就被优化为寄存器或ECX时,都不会不要让它“不是指针”。

答案 8 :(得分:3)

如果编译器内联通过静态而不是动态绑定调用的成员函数,则它可能能够优化this指针。举个简单的例子:

#include <iostream>

using std::cout;
using std::endl;

class example {
  public:
  int foo() const { return x; }
  int foo(const int i) { return (x = i); }

  private:
  int x;
};

int main(void)
{
  example e;
  e.foo(10);
  cout << e.foo() << endl;
}

带有-march=x86-64 -O -S标志的GCC 7.3.0能够将cout << e.foo()编译为三个指令:

movl    $10, %esi
leaq    _ZSt4cout(%rip), %rdi
call    _ZNSolsEi@PLT

这是对std::ostream::operator<<的呼叫。请记住,cout << e.foo();std::ostream::operator<< (cout, e.foo());的语法糖。 operator<<(int)可以用两种方式编写:static operator<< (ostream&, int)作为非成员函数,其中左侧的操作数是显式参数,或者operator<<(int)作为成员函数,在其中隐式this

编译器能够推断出e.foo()将始终是常数10。由于64位x86调用约定是要在寄存器中传递函数参数,因此该指令会向下编译为单个movl指令,该指令会将第二个函数参数设置为10leaq指令将第一个参数(可以是显式ostream&或隐式this)设置为&cout。然后程序对函数进行call

不过,在更复杂的情况下(例如,如果您有一个将example&作为参数的函数),编译器需要查找this,因为this可以说明编程要使用的实例,因此要查找哪个实例的x数据成员。

考虑以下示例:

class example {
  public:
  int foo() const { return x; }
  int foo(const int i) { return (x = i); }

  private:
  int x;
};

int bar( const example& e )
{
  return e.foo();
}

函数bar()被编译成一些样板和指令:

movl    (%rdi), %eax
ret

您从上一个示例中还记得x86-64上的%rdi是第一个函数参数,即对this的调用的隐式e.foo()指针。将其放在括号(%rdi)中意味着在该位置查找变量。 (由于example实例中的唯一数据是x,因此在这种情况下&e.x&e相同。)将内容移至%eax设置返回值。

在这种情况下,编译器需要this的隐式foo(/* example* this */)参数才能找到&e,因此找到&e.x。实际上,在成员函数(不是static)中,xthis->x(*this).x都是同一意思。

答案 9 :(得分:3)

“ this”还可以防止函数参数产生阴影,例如:

class Vector {
   public:
      double x,y,z;
      void SetLocation(double x, double y, double z);
};

void Vector::SetLocation(double x, double y, double z) {
   this->x = x; //Passed parameter assigned to member variable
   this->y = y;
   this->z = z;
}

(显然,不建议编写此类代码。)

答案 10 :(得分:3)

正如在大多数答案中所反复提到的那样,

this确实是一个运行时指针(尽管编译器隐式地为提供了)。它用于指示给定成员函数在调用时将在哪个类实例上进行操作;对于类c的任何给定实例C,当调用任何成员函数cf()时,将向c.cf()提供等于{{1}的this指针}(这自然也适用于类型为&c的任何结构s,当调用成员函数S时,将用于更清晰的演示)。它甚至可以像任何其他指针一样具有cv资格,并且具有相同的效果(但不幸的是,由于特殊,语法也不相同);通常用于s.sf()正确性,而很少用于const正确性。

volatile

示例输出:

template<typename T>
uintptr_t addr_out(T* ptr) { return reinterpret_cast<uintptr_t>(ptr); }

struct S {
    int i;

    uintptr_t address() const { return addr_out(this); }
};

// Format a given numerical value into a hex value for easy display.
// Implementation omitted for brevity.
template<typename T>
std::string hex_out_s(T val, bool disp0X = true);

// ...

S s[2];

std::cout << "Control example: Two distinct instances of simple class.\n";
std::cout << "s[0] address:\t\t\t\t"        << hex_out_s(addr_out(&s[0]))
          << "\n* s[0] this pointer:\t\t\t" << hex_out_s(s[0].address())
          << "\n\n";
std::cout << "s[1] address:\t\t\t\t"        << hex_out_s(addr_out(&s[1]))
          << "\n* s[1] this pointer:\t\t\t" << hex_out_s(s[1].address())
          << "\n\n";

不能保证这些值,可以很容易地从一个执行更改为下一个执行;通过使用构建工具,在创建和测试程序时最容易观察到这一点。


从机械上讲,它类似于添加到每个成员函数的参数列表开头的隐藏参数。 Control example: Two distinct instances of simple class. s[0] address: 0x0000003836e8fb40 * s[0] this pointer: 0x0000003836e8fb40 s[1] address: 0x0000003836e8fb44 * s[1] this pointer: 0x0000003836e8fb44 可以看作是x.f() cv的特殊变体,尽管出于语言原因其格式不同。实际上,there were recent proposals by both Stroustrup and Sutter统一了f(cv X* this)x.f(y)的调用语法,这将使这种隐式行为成为一个明确的语言规则。不幸的是,它担心可能会给库开发人员带来一些意外的惊喜,因此尚未实施;据我所知,最近的提议是a joint proposal, for f(x,y) to be able to fall back on x.f(y) if no f(x,y) is found,类似于f(x, y)和成员函数std::begin(x)之间的交互。

在这种情况下,x.begin()更类似于普通的指针,程序员可以手动指定它。如果发现一种解决方案可以在不违反最小惊讶原则(或避免其他任何顾虑)的情况下允许采用更健壮的形式,则也可以隐式生成与this等效的内容作为非成员函数也是如此。


与此相关,需要注意的重要一件事是this是实例的地址,该实例看到的;尽管指针本身是运行时对象,但并不总是具有您认为的值。当查看具有更复杂的继承层次结构的类时,这变得很重要。具体来说,当查看包含成员函数的一个或多个基类与派生类本身的地址不同时。我特别想到三种情况:

请注意,这些是使用MSVC进行演示的,并且通过undocumented -d1reportSingleClassLayout compiler parameter输出了类布局,这是因为我发现它比GCC或Clang等效项更易于阅读。

  1. 非标准布局:当类为标准布局时,实例的第一个数据成员的地址与实例本身的地址完全相同;因此,this可以说等同于第一个数据成员的地址。即使该数据成员是基类的成员,只要派生类继续遵循标准布局规则,这也将成立。 ...相反,这也意味着,如果派生类不是标准布局,那么将不再保证。

    this

    示例输出:

    struct StandardBase {
        int i;
    
        uintptr_t address() const { return addr_out(this); }
    };
    
    struct NonStandardDerived : StandardBase {
        virtual void f() {}
    
        uintptr_t address() const { return addr_out(this); }
    };
    
    static_assert(std::is_standard_layout<StandardBase>::value, "Nyeh.");
    static_assert(!std::is_standard_layout<NonStandardDerived>::value, ".heyN");
    
    // ...
    
    NonStandardDerived n;
    
    std::cout << "Derived class with non-standard layout:"
              << "\n* n address:\t\t\t\t\t"                      << hex_out_s(addr_out(&n))
              << "\n* n this pointer:\t\t\t\t"                   << hex_out_s(n.address())
              << "\n* n this pointer (as StandardBase):\t\t"     << hex_out_s(n.StandardBase::address())
              << "\n* n this pointer (as NonStandardDerived):\t" << hex_out_s(n.NonStandardDerived::address())
              << "\n\n";
    

    请注意,Derived class with non-standard layout: * n address: 0x00000061e86cf3c0 * n this pointer: 0x00000061e86cf3c0 * n this pointer (as StandardBase): 0x00000061e86cf3c8 * n this pointer (as NonStandardDerived): 0x00000061e86cf3c0 的指针StandardBase::address()this不同,即使在同一实例上调用也是如此。这是因为后者对vtable的使用导致编译器插入隐藏成员。

    NonStandardDerived::address()
  2. 虚拟基类:由于虚拟基位于最衍生类之后,因此提供给从虚拟基继承的成员函数的class StandardBase size(4): +--- 0 | i +--- class NonStandardDerived size(16): +--- 0 | {vfptr} | +--- (base class StandardBase) 8 | | i | +--- | <alignment member> (size=4) +--- NonStandardDerived::$vftable@: | &NonStandardDerived_meta | 0 0 | &NonStandardDerived::f NonStandardDerived::f this adjustor: 0 指针与提供给派生类本身的成员的那个。

    this

    示例输出:

    struct VBase {
        uintptr_t address() const { return addr_out(this); }
    };
    struct VDerived : virtual VBase {
        uintptr_t address() const { return addr_out(this); }
    };
    
    // ...
    
    VDerived v;
    
    std::cout << "Derived class with virtual base:"
              << "\n* v address:\t\t\t\t\t"              << hex_out_s(addr_out(&v))
              << "\n* v this pointer:\t\t\t\t"           << hex_out_s(v.address())
              << "\n* this pointer (as VBase):\t\t\t"    << hex_out_s(v.VBase::address())
              << "\n* this pointer (as VDerived):\t\t\t" << hex_out_s(v.VDerived::address())
              << "\n\n";
    

    再一次,由于Derived class with virtual base: * v address: 0x0000008f8314f8b0 * v this pointer: 0x0000008f8314f8b0 * this pointer (as VBase): 0x0000008f8314f8b8 * this pointer (as VDerived): 0x0000008f8314f8b0 继承的this具有与VDerived不同的起始地址,基类的成员函数提供了不同的VBase指针。本身。

    VDerived
  3. 多重继承:可以预期,多重继承很容易导致传递给一个成员函数的class VDerived size(8): +--- 0 | {vbptr} +--- +--- (virtual base VBase) +--- VDerived::$vbtable@: 0 | 0 1 | 8 (VDerivedd(VDerived+0)VBase) vbi: class offset o.vbptr o.vbte fVtorDisp VBase 8 0 4 0 指针与this不同的情况。指针传递给另一个成员函数,即使两个函数都用同一实例调用。这可以适用于除第一个基类以外的任何基类的成员函数,类似于使用非标准布局类时(其中,第一个基类之后的所有基类都在与派生类本身不同的地址处开始)...但是对于this函数,当多个成员提供具有相同签名的虚函数时,可能会特别令人惊讶。

    virtual

    示例输出:

    struct Base1 {
        int i;
    
        virtual uintptr_t address() const { return addr_out(this); }
        uintptr_t raw_address() { return addr_out(this); }
    };
    struct Base2 {
        short s;
    
        virtual uintptr_t address() const { return addr_out(this); }
        uintptr_t raw_address() { return addr_out(this); }
    };
    struct Derived : Base1, Base2 {
        bool b;
    
        uintptr_t address() const override { return addr_out(this); }
        uintptr_t raw_address() { return addr_out(this); }
    };
    
    // ...
    
    Derived d;
    
    std::cout << "Derived class with multiple inheritance:"
              << "\n  (Calling address() through a static_cast reference, then the appropriate raw_address().)"
              << "\n* d address:\t\t\t\t\t"               << hex_out_s(addr_out(&d))
              << "\n* d this pointer:\t\t\t\t"            << hex_out_s(d.address())                          << " (" << hex_out_s(d.raw_address())          << ")"
              << "\n* d this pointer (as Base1):\t\t\t"   << hex_out_s(static_cast<Base1&>((d)).address())   << " (" << hex_out_s(d.Base1::raw_address())   << ")"
              << "\n* d this pointer (as Base2):\t\t\t"   << hex_out_s(static_cast<Base2&>((d)).address())   << " (" << hex_out_s(d.Base2::raw_address())   << ")"
              << "\n* d this pointer (as Derived):\t\t\t" << hex_out_s(static_cast<Derived&>((d)).address()) << " (" << hex_out_s(d.Derived::raw_address()) << ")"
              << "\n\n";
    

    由于每个Derived class with multiple inheritance: (Calling address() through a static_cast reference, then the appropriate raw_address().) * d address: 0x00000056911ef530 * d this pointer: 0x00000056911ef530 (0x00000056911ef530) * d this pointer (as Base1): 0x00000056911ef530 (0x00000056911ef530) * d this pointer (as Base2): 0x00000056911ef530 (0x00000056911ef540) * d this pointer (as Derived): 0x00000056911ef530 (0x00000056911ef530) 明确地是一个单独的函数,因此我们希望每个raw_address()遵循相同的规则,因此Base2::raw_address()将返回与Derived::raw_address()不同的值。但是,由于我们知道派生函数将始终调用最派生形式,因此从对address()的引用中调用Base2时是正确的吗?这是由于一个小的编译器技巧(称为“ adjustor thunk”),它是一个辅助程序,它使用基类实例的this指针,并在必要时将其调整为指向派生最多的类。

    class Derived   size(40):
            +---
            | +--- (base class Base1)
     0      | | {vfptr}
     8      | | i
            | | <alignment member> (size=4)
            | +---
            | +--- (base class Base2)
    16      | | {vfptr}
    24      | | s
            | | <alignment member> (size=6)
            | +---
    32      | b
            | <alignment member> (size=7)
            +---
    Derived::$vftable@Base1@:
            | &Derived_meta
            |  0
     0      | &Derived::address 
    Derived::$vftable@Base2@:
            | -16
     0      | &thunk: this-=16; goto Derived::address 
    Derived::address this adjustor: 0
    

如果您感到好奇,请随意修改this little program,以查看多次运行地址后地址如何变化,或者在某些情况下地址值可能与您不同期待。

答案 11 :(得分:2)

this是一个指针。就像每个方法都包含一个隐式参数一样。您可以想象使用普通的C函数并编写如下代码:

Socket makeSocket(int port) { ... }
void send(Socket *this, Value v) { ... }
Value receive(Socket *this) { ... }

Socket *mySocket = makeSocket(1234);
send(mySocket, someValue); // The subject, `mySocket`, is passed in as a param called "this", explicitly
Value newData = receive(socket);

在C ++中,类似的代码可能如下所示:

mySocket.send(someValue); // The subject, `mySocket`, is passed in as a param called "this"
Value newData = mySocket.receive();