Hello, and welcome to C++ Weekly. I'm your host, Jason Turner. I'm going to pick up where I last
left off on understanding the difference between 'const' and non-'const' member functions by trying
to give you some insight into how these are actually implemented by the compiler.
This episode of C++ Weekly is sponsored by me. Be sure to check out the video
description for important links about upcoming events, sponsoring the channel with Patreon,
buying copies of my Best Practices and Puzzler books and how to contact me if you are interested
in on-site training or remote code reviews. Okay. So we left off here with some simple examples
of calling a 'const' member function versus a non-'const' member function,
and we're gonna try to sort out, like I said, how this actually is implemented by the compiler.
Now this code is all inlined and stuff, so we can't actually see anything. All we see
is a call to "vprint" and the string "const". The compiler even eliminated this "non-const" string
from the binary because the non-'const' version of the function is never called. So that is the
first important thing to be aware of - this is a compile time choice. This is a function overload.
It is not a runtime choice. The compiler knows if the function should be called based on whether
it's a 'const' object or a non-'const' object. So we're going to strip back some of this to just
the bare minimum of things that we need to actually be able to observe.
So there's a couple of things to look at. The first is this "rsp+15" copied to "rdi,"
and then the second thing is this call to "print_string() const." So the compiler
knows at compile time which version I should be calling. When I remove the 'const' here,
then I can see here that the compiler is generating a call to the non-'const'
overload for "print_string;" and in both cases we have this "rdi."
Let's take a quick look at what that "rdi" is.
The compiler has done a little bit too much optimization for us to really be able to read
this. Let's take this back to -O0, and hopefully it doesn't do too little optimization for us to
look at it. I'm adding the three integers x, y, and z together, and I see that the compiler
is dealing with "edi," "esi" and "edx." These are the extended 32-bit registers "di," "si" and "dx."
These are the first three parameters passed to the function in order - "edi," "esi," "edx."
Okay, so let's look back down here again. This, now the de-optimized version of it, is taking this
"rbp" and putting it in "rdi." The very first parameter to the function "print_string" is
a pointer to the object itself. It is a hidden first parameter that the compiler has done for us.
Okay. We've done a little bit of code reorganization - I changed the name of this thing
from "string" to "data" because we're kind of changing the nature of what it does,
(I didn't want the naming in the comments to get too far out of sync with the code
example) and I've turned some level of optimization back on, which is
gonna go both ways. It's gonna be a little bit harder for us to see what's happening here,
but I said that there is a hidden first parameter, and I showed you that by showing that the compiler
sets up this thing on "edi" before calling the member function. Now that hidden first parameter
is, in fact, the 'this' pointer so; if we're going to do this,
increment my value here like we are doing,
this is, for the sake of this code, exactly equivalent to if I had done this: if I had said,
"Please increment MY "m_value"," the thing that 'this' points to.
So 'this' is a keyword in C++. It has been there since the beginning. Let's go ahead and do this:
I want to say, "I want an explicit pointer to me," so I have this 'this' if you will.
So we're jumping through a couple of hoops here to illustrate a point, but I can explicitly
get a pointer to me. I can assign it to another thing, I can operate on it like that.
Now if I change this to a 'const' member function, we get to really see what is happening here -
"invalid conversion from 'const data*' to 'data' pointer." 'this', the "this pointer" (in quotes)
changes from being a non-'const' pointer to the class type to being a 'const' pointer to
the class type; and this pointer was set up in the call stack by the compiler for us when we
called the member function. So this doesn't work because it is a 'const' to non-'const' conversion.
Let's go ahead and make this 'const' and now we can really see what is happening:
"increment of member 'm_value' in a read-only object." So in the case that I did an explicit
'this' - "increment of member 'm_value' in read-only object." We're getting
the same kind of thing here. So we have a 'const' pointer to 'this'
and that is why this code can't compile. So let's take it and invert it a little bit
like this, and now this error that we saw in the previous episode hopefully makes a little bit
more sense. We have "passing 'const data' as 'this' argument." Well from your perspective
you didn't pass it an argument. From the compiler's perspective it did - it was that
first argument. Interestingly though, this isn't all of the story, so let's go ahead
and play around a little bit. Let's just say I have an 'int' that I'm passing to this thing,
and we'll take this from 'const' and we'll pass in a value to it. Now I've intentionally removed the
implementation so that we can see a couple of things. The first is that "esi" (now we
were able to reason about that being the second parameter to the function) is set up with the
value 4. So now the first explicit parameter, this 'int' is actually the second parameter,
and we can still see that we're setting up a pointer to the object on the stack
"my_data" into "rdi", and that is the first value. So the first value in this case,
the first argument is this, the second argument is 4. Now it's starting to make sense hopefully.
Now the next thing that I want to illustrate is going to take a little bit more setup to be
able to make my point. I'm going to go ahead and turn optimizations all the way up so that we get
as little stack management and whatever from the compiler that we want to see. Now, keeping in mind
this argument is the first parameter, that's still the one going into "di", the second parameter, 4,
is still going into "esi", that is the second parameter. We expect a third parameter at some
point to show up in my next example, and this is going to be going into the "dx" register. Keep
in mind this is all 64-bit itanium ABI calling conventions. This is not something that happens
exactly the same on every compiler. You're going to see this on any compiler that uses the itanium
ABI, but, for example, on ARM it might look different; but the concepts will stay the same.
So I'm going to create a little bit more bulk here and just create an array of 32 'ints'
because I need the size of data to no longer fit in a register. Now I'm going to change this
so that it returns a "data." That's what we know - it's returning a large object. So the compiler
is going to have to do some other things. It is setting up some more stack space for us,
it is moving 4 now into "edx." That means that 4 just became the third argument.
We still have the 'this' pointer going in to "edi" so that is still 'this'; and the second argument
becomes the return value, return value pointer effectively. So the first argument is a pointer
to the 'this' object in this case, "my_data." The second argument is a pointer to the stack
space where the return value is going to live, and now the third argument is the value 4.
From our perspective, this function has one argument. From the perspective of the compiler
it has definitely at least two - it has the 'this' and the argument that you're explicitly passing.
From the standpoint of these calling conventions it has three - the return value location,
the 'this' pointer and the value that you're actually passing to it.
Let's see if, for the fun of it, we can get an ARM compiler to show us something interesting
so that we can look at something perhaps just a little bit different. Okay, so we see "r2"
is getting the value 4. We see that "r1" appears to be getting this stack space that was set up
for those 32 integers, and "r0" appears to be the 'this' pointer. So it did approximately the same
thing with your 32-bit ARM calling conventions as well. So I hope that this was an interesting
episode for you and perhaps gave you a little bit more insight as to what the compiler actually does
when you call a member function and how it deals with 'const' vs non-'const' member functions,
as well as some hidden parameters when you're calling member functions,
or in any kind of function really that you may not have been aware of.