C++ copy constructor causing code not to compile ( gcc ) - c++

I have the following code which doesn't compile. The compiler error is:
"error: no matching function to call B::B(B)",
candidates are B::B(B&) B::B(int)"
The code compiles under either of the following two conditions:
Uncommenting the function B(const B&)
change 'main' to the following
int main()
{
A a;
B b0;
B b1 = b0;
return 0;
}
If I do 1, the code compiles, but from the output it says it's calling the 'non const copy constructor'.
Can anyone tell me what's going on here?
using namespace std;
class B
{
public:
int k;
B()
{
cout<<"B()"<<endl;
}
B(int k)
{
cout<<"B(int)"<<endl;
this->k = k;
}
/*B(const B& rhs) {
cout<<"Copy constructor"<<endl;
k = rhs.k;
}*/
B(B& rhs)
{
cout<<"non const Copy constructor"<<endl;
k = rhs.k;
}
B operator=(B& rhs)
{
cout<<"assign operator"<<endl;
k = rhs.k;
return *this;
}
};
class A
{
public:
B get_a(void)
{
B* a = new B(10);
return *a;
}
};
int main()
{
A a;
B b0 = a.get_a(); // was a.just();
B b1 = b0 ;
return 0;
}

I've done some extra reading into this, and as I suspected all along, the reason why this occurs is due to return value optimization. As the Wikipedia article explains, RVO is the allowed mechanism by which compilers are allowed to eliminate temporary objects in the process of assigning them or copying them into permanent variables. Additionally, RVO is one of the few features (if not the only) which are allowed to violate the as-if rule, whereby compilers are only allowed to make optimizations only if they have the same observable behaviours as if the optimization were never made in the first place -- an exemption which is key in explaining the behaviour here (I admittedly only learned of that exemption as I was researching this question, which is why I was also confused initially).
In your case, GCC is smart enough to avoid one of the two copies. To boil your code down to a simpler example
B returnB()
{
B a;
B* b = &a;
return *b;
}
int main()
{
B c = returnB();
return 0;
}
If one follows the standard and does not perform RVO, two copies are made in the process of making c -- the copy of *b into returnB's return value, and the copy of the return value into c itself. In your case, GCC omits the first copy and instead makes only one copy, from *b directly into c. That also explains why B(B&) is called instead of B(const B&) -- since *b (a.k.a. a) is not a temporary value, the compiler doesn't need to use B(const B&) anymore and instead chooses the simpler B(B&) call instead when constructing c (a non-const overload is always automatically preferred over a const overload if the choice exists).
So why does the compiler still give an error if B(const B&) isn't there? That's because your code's syntax must be correct before optimizations (like RVO) can be made. In the above example, returnB() is returning a temporary (according to the C++ syntax rules), so the compiler must see a B(const B&) copy constructor. However, once your code is confirmed to be grammatically correct by the compiler, it then can make the optimization such that B(const B&) is never used anyway.
EDIT: Hat tip to Charles Bailey who found the following in the C++ standard
12.2 [class.temporary]: "Even when the creation of the temporary is avoided,
all the semantic restrictions must be
respected as if the temporary object
was created."
which just reinforces and confirms the need for a copy constructor taking a reference to const when temporaries are to be copied for construction (irrespective of whether or not the constructor is actually used)

Your get_a() function returns an object B, not a reference (but leaks the newly-created B object). Also, for assignments to work as you're doing, you need to make sure your assignment operator and copy constructors are both taking const B& arguments — then B b1 = b0 will work in this case. This works:
class B
{
public:
int k;
B() { cout<<"B()"<<endl; }
B(int k) {
cout<<"B(int)"<<endl;
this->k = k;
}
B(const B& rhs) {
cout<<"non const Copy constructor"<<endl;
k = rhs.k;
}
B operator=(const B& rhs) {
cout<<"assign operator"<<endl;
k = rhs.k;
return *this;
}
};
class A {
public:
B* get_a(void) {
B* a = new B(10);
return a;
}
B get_a2(void) {
B a(10);
return a;
}
};
int main() {
A a;
B b0 = *a.get_a(); // bad: result from get_a() never freed!
B b1 = a.get_a2(); // this works too
return 0;
}

Short explanation: functions that return by value create a temporary object which is treated as constant and therefore cannot be passed to functions accepting by reference, it can only be passed to functions accepting const reference. If you really allocate an object in get_a(), you should really be returning a pointer (so that you remember to delete it, hopefully) or in the worst case - a reference. If you really want to return a copy - create the object on the stack.
Long explanation: To understand why your code doesn't compile if there is only "non-const copy constructor"1, you need to be familiar with the terms lvalue and rvalue. They originally meant that rvalues can only stand on the right side of operator = (assignment) while lvalues can stand also on the left side. Example:
T a, b;
const T ac;
a = b; // a can appear on the left of =
b = a; // so can b => a and b are lvalues in this context
ac = a; // however, ac is const so assignment doesn't make sense<sup>2</sup>, ac is a rvalue
When the compiler is performing overload resolution (finding which overload of a function/method best match the provided arguments) it will allow lvalues to match parameters passed by value3, reference and const reference types. However, it will match rvalues only against value3 and const reference parameters. And that's because in some sense, since rvalues cannot be put on the left side of operator =, they have read-only semantic, and when it shouldn't be allowed to modify them. And when you accept a parameter through non-const reference, it's implied that you'll somehow change this parameter.
The last piece of the puzzle: temporary objects are rvalues. Function returning by value creates a temporary object with very limited lifespan. Because of its limited lifespan it's considered const, and is therefore a rvalue. And this rvalue doesn't match functions with parameters by non-const reference. Examples:
void f_cref(const A& a) { std::cout << "const ref" << std::endl; }
void f_ref(A& a) { std::cout << "non-const ref" << std::endl; }
A geta() { return A(); }
A a;
const A ac;
f_ref(a); // ok, a is a lvalue
f_ref(ac); // error, passing const to non-const - rvalue as lvalue - it's easy to spot here
f_cref(a); // ok, you can always pass non-const to const (lvalues to rvalues)
f_ref(geta()); // error, passing temporary and therefore const object as reference
f_cref(geta()); // ok, temporary as const reference
Now you have all the information to figure out why your code doesn't compile. Copy constructor are like regular functions.
I have oversimplified things a bit, so better, more complete and correct explanation can be found at this excellent Visual C++ Studio Team blog post about rvalue references, which also addresses the new C++ 0x feature "rvalue references"
1 - there's no such thing as non-const copy constructor. The copy constructor accepts const reference, period.
2 - you can probably put const object on the left of = if it has its operator = declared const. But that would be terrible, terrible, nonsensical thing to do.
3 - actually, you wouldn't be able to pass const A by value if A doesn't have a copy constructor - one that accepts const A& that is.

The line B b1 = b0; is the culprit. This line requires calling a copy constructor. You could fix the code by writing B b1(b0) instead, or by defining a copy constructor, which takes a const B&, but not a B&.

Related

Move construction and assignment of class with constant member

I have a class with a const member that needs both a move constructor and assignment.
I implemented it the following way:
struct C
{
const int i;
C(int i) : i{i} {}
C(C && other) noexcept: i{other.i} {}
C & operator=(C && other) noexcept
{
//Do not move from ourselves or all hell will break loose
if (this == &other)
return *this;
//Call our own destructor to clean-up before moving
this->~C();
//Use our own move constructor to do the actual work
new(this) C {std::move(other)};
return *this;
}
//Other stuff here (including the destructor)....
}
This compiles and works as expected.
The question is whether this is the normal way to implement such a move assignment or there is a less contrived way to do it?
Unfortunately this is undefined behavior. You cannot overwrite a const object like this and refer to it by the same name afterwards. This is covered by [basic.life]/8
If, after the lifetime of an object has ended and before the storage which the object occupied is reused or released, a new object is created at the storage location which the original object occupied, a pointer that pointed to the original object, a reference that referred to the original object, or the name of the original object will automatically refer to the new object and, once the lifetime of the new object has started, can be used to manipulate the new object, if: [...]
the type of the original object is not const-qualified, and, if a class type, does not contain any non-static data member whose type is const-qualified or a reference type, [...]
The simple fix is to make i non const and private and just use a getter to stop any modification from happening.
After some research I came to the following.
There are related questions:
move assignment to object with const value
Placement new and assignment of class with const member
assignment of class with const member
The relevant information was found in:
https://timsong-cpp.github.io/cppwp/basic.life
https://timsong-cpp.github.io/cppwp/class.copy.assign
https://en.cppreference.com/w/cpp/language/lifetime#Storage_reuse
https://en.cppreference.com/w/cpp/utility/launder
What clarified this for me was the example in https://en.cppreference.com/w/cpp/utility/launder
struct X {const int i; };
X * p = new X{0};
X * np = new (p) X{1};
This will result in undefined behavior:
const int i = p->i
But the following is valid:
const int i = np->i;
Per my original question, a modified version of the move assignment would be needed:
struct C
{
const int i;
C() : i{} {}
C(C && other) noexcept: i{other.i} {}
C & operator=(C && other) noexcept
{
if (this == &other) return *this;
this->~C(); //Ok only if ~C is trivial
return *(new(this) C {std::move(other)});
}
}
C a;
C b;
b = std::move(a);
//Undefined behavior!
const int i = b.i;
This would work as expected but would result in undefined behavior for the following reasons.
When the destructor is invoked the objects' lifetime ends. Following that it is safe to call the move constructor. But at any point the compiler is free to assume that the content of b never changes. Thus, by using our move assignment we have a contradiction that results in undefined behavior.
On the other hand, although the return value from the placement new is the same as this, when the compiler performs access through that, returned, pointer/reference it must not assume anything about that object.
Given that C& C::operator=(C&&) returns the result of the placement new, the following should be valid (but not really useful).
const int i = (b = std::move(a)).i;
Thank to #NathanOliver, whos' answer was the correct one all along and, also, to him and #SanderDeDycker for playing brain ping-pong with me.
As of c++20, this can now be done without UB. This is because the basic.life now allows replacing objects that contain consts. Prior to c++20 this was disallowed.
Secondly, lack of UB can be proven using constexpr compile time evaluation. Compilers are required to detect UB in compile time evaluation. However, placement new is still not consteval so while legal in regular code w/o UB, it can't be checked in compile time functions. But c++20 provides 2 functions that will work, std::destroy_at and std::construct_at.
This code allows assignment using your example:
#include <memory>
struct C
{
const int i;
constexpr C(int i) : i{ i } {}
constexpr C(const C& other) noexcept = default;
constexpr C& operator=(const C& other) noexcept
{
//Do not move from ourselves or all hell will break loose
if (this == &other)
return *this;
//Call our own destructor to clean-up before moving
std::destroy_at(this);
std::construct_at(this, other);
return *this;
}
};
// Compilers are required to detect any UB evaluating this
consteval int foo()
{
C a(1), b(2); // create two difference objects
C c(b); // CTOR
a = c;
return a.i; // returns 2;
}
int main()
{
return foo();
}

what if C++ class contains both const reference and non-const reference copy constructor?

snippet 1:
#include<iostream>
using namespace std;
class C{
public:
C(){}
C(const C& c){
cout<<"const copy constructor called"<<endl;
}
};
int main(){
C c1;
C c2 = c1;
return 0;
}
output: const copy constructor called
snippet 2:
#include<iostream>
using namespace std;
class C{
public:
C(){}
C(const C& c){
cout<<"const copy constructor called"<<endl;
}
C(C& c){
cout<<"non-const copy constructor called.\t "<<endl;
}
};
int main(){
C c1;
C c2 = c1;
return 0;
}
output: non-const copy constructor called
snippet 3:
#include<iostream>
using namespace std;
class C{
public:
C(){}
C(const C& c){
cout<<"const copy constructor called"<<endl;
}
C(C c){
cout<<"non-const copy constructor called.\t "<<endl;
}
};
int main(){
C c1;
C c2 = c1;
return 0;
}
output: error: copy constructor must pass its first argument by reference
I am so confused about:
for snippet 2, why the non-const copy constructor here is valid? why non-const copy constructor was called, rather than the const one.
for snippet 3, I know that copy constructor must use const reference to avoid infinite recursion. But Here class C has got C(const C& c), C(C c) won't cause infinite recursion, why it still doesn't work?
Snippet 1: One standard copy constructor with const T&. Happy world.
Snippet 2:
What you have effectively done is overloaded the copy constructor - one that takes a reference T& and the other that takes a constant reference const T&.
Please note: Any constructor for a class T that has one mandatory argument of type T & or const T & (it may also have further, defaulted arguments) is a copy constructor.
So, for the compiler, it all just boils down to finding the Best Fit for overload resolution and it is done as:
Standard conversion sequence S1 is a better conversion sequence than
standard conversion sequence S2 if:
....
S1 and S2 are reference bindings (8.5.3), and the types to which the references refer are the same type except for top-level cv-qualifiers,
and the type to which the reference initialized by S2 refers is more
cv-qualified than the type to which the reference initialized by S1
refers.
So writing
C c1;
C c2 = c1;
will call the non-const copy constructor since it is a better match, but,
writing,
const C c1;
C c2 = c1;
will call the const copy constructor (you can check) since now the copy constructor with const is the only viable match.
Snippet 3 is just plain wrong for the compiler.
C(C c){
cout<<"non-const copy constructor called.\t "<<endl;
}
You can't have a method with a signature C(C c). The compiler thinks that you are trying to write a copy constructor and missed writing the & and hence reports the error. Remove it and it works fine.
#Unless you have a very good reason, never ever use C(C& c) for your copy constructor. Don't skip const because mutating the object from which you are making a copy doesn't make much sense.
for snippet 2, why the non-const copy constructor here is valid? why non-const copy constructor was called, rather than the const one.
Consider your code for this question, but with the change below with the comment // (*):
int main(){
const C c1; // (*) <- See change here
C c2 = c1;
return 0;
}
This calls the const copy ctor version. It really has nothing to do with the function happening to be a constructor - if a function has two overloads, one taking a reference, and one a const reference, then non-const objects will be called with the first, and const objects with the second.
for snippet 3, I know that copy constructor must use const reference to avoid infinite recursion. But Here class C has got C(const C& c), C(C c) won't cause infinite recursion, why it still doesn't work?
Consider the following code, and note that there is no invocation going on (the content of main is pretty much erased).
#include<iostream>
using namespace std;
class C{
public:
C(){}
C(const C& c){
cout<<"const copy constructor called"<<endl;
}
C(C c){
cout<<"non-const copy constructor called.\t "<<endl;
}
};
int main(){
// Note that nothing is creating C instances at all.
return 0;
}
This results in the exact same error - the compiler simply refuses to compile a class with this interface, regardless of whether something is attempting to call it or not.
Quoting from an answer to this question "it's forbidden by the standard in §12.8/3:
A declaration of a constructor for a class X is ill-formed if its first parameter is of type (optionally cv- qualified) X and either there are no other parameters or else all other parameters have default arguments.
"

C++ referencing operator in declaration

I am a beginner in C++, and this must be a really basic question. I understand & stands for referencing operation. For example, a = &b assigns the address of b to a. However, what does & in a declaration such as the following mean?
className& operator=(const className&);
Do the following make sense and what is the difference between the last line and the following?
className* operator=(const className*);
From the answers below, it seems --- as I understand it --- that the following is valid too. Is it? If it is, how is it different from the version with "&"?
className operator=(const className);
After reading the following answers and some more outside, I realized part of my original confusion stems from mixing up reference as in general computer science and reference type as in C++. Thank you all who answered my question. All the answers clarify my understanding to different degrees, even though I can only pick one as the accepted answer.
The token & has three distinct meanings in C++, two of which are inherited from C, and one of which is not.
It's the bitwise AND operator (unless overloaded).
It's the address operator, which acts on an lvalue to yield a pointer to it. (Unless overloaded.) This is what is happening in a = &b.
It denotes a reference type. const className& as a parameter is a const reference to an object of type className, so when the function is called, the argument will be passed by reference, but it won't be allowed to be modified by the function. The function you gave also returns a reference.
Assignment Operator
Understanding is best gained by example:
class A {
int x;
public:
A(int value) : x(value) {}
A& operator=(const A& from) { // Edit: added missing '=' in 'operator='
x = from.x;
return *this;
}
};
A m1(7);
A m2(9);
m2 = m1; /// <--- calls the function above, replaces m2.x with 7
Here, we defined the assignment operator. This special method is designed to provide assignment capability to objects.
The reason that it returns a reference to *this is so you can chain assignments without excessive memory copies:
A m3(11);
m3 = m1 = m2; /// <--- now, m3.x and m1.x both set to m2.x
Expanded as follows:
m3 = ( something )
where
(something) is a reference to the object m1
by result of call to m1.operator=(m2) method
such that
the returned reference is then passed into m3.operator(...)
Chaining lets you do this:
(m1=m2).function(); // calls m1.function after calling assignment operator
Libraries such as boost leverage this pattern (for a different type of chaining example, see the program options library in boost) and it can be useful when developing a domain specific 'language'.
If instead full objects were returned from operator=, the chained assignment would at a minimum involve multiple extra copies of the objects, wasting CPU cycles and memory. In many cases things would not work properly because of the copy.
Essentially, using a reference avoids a copy.
Note
In reality, (simplified explanation) a reference is just a fancy syntax for a C pointer.
In the common case, you can then write code with A.x instead of A->x.
Caution
Returning a pure reference from a method is often dangerous; newcomers can be tempted to return a reference to an object constructed locally inside the method on the stack, which depending on the object can lead to obscure bugs.
Your pointer example
It depends on what you return from the body of the method, but regardless, the following would instead return a pointer to some instance of className:
className* operator=(const className*);
This will compile and it even seems to work (if you return this from the method), but this does violate the Rule of Least Surprise, as it is likely anyone else attempting to use your code would not expect the assignment operator to return a pointer.
If you think about base types:
int x=1; int y=2; int z; z=y=x;
will never ever do anything other than return integers - so having operator= return the assigned to object is consistent)
It also doesn't let you do this:
(m1 = m2).something
It also allows you to pass NULL which is something assignment operators don't typically want to care about.
Instead of writing
blah& operator(const blah& x) { a = x.a ; return *this; }
You would need to write:
blah* operator(const blah* x) {
if (x) { a = x->a ; return this; }
else { /*handle null pointer*/
}
It means the function takes a reference to a const className and returns a reference to a className
Say you have a class like below:
class className
{
public:
className& operator=(const className& rhs)
{
this->a_ = rhs.a_;
this->b_ = rhs.b_;
return *this;
}
private:
std::string a_;
std::string b_;
};
You can use the assignment operator of the class as below:
className a;
className b;
className c;
c = b = a;
The above line is executed as:
c.operator=(b.operator=(a));
Now take another class that has the operator= defined using the second form:
class className2
{
public:
className2* operator=(const className2* rhs)
{
this->a_ = rhs->a_;
this->b_ = rhs->b_;
return this;
}
private:
std::string a_;
std::string b_;
};
You can use the assignment operator of the class as below:
className2 obj1;
className2 obj2;
className2 obj3;
obj2 = &obj1;
obj3 = &obj2;
The above lines are executed as:
obj2.operator=(&obj1);
obj3.operator=(&obj2);
However, such coding is not intuitive. You don't assign a pointer to an object. It is more intuitive to say:
className obj1;
className* objPtr = NULL;
objPtr = &obj1;
a = &b
Here, & gives address of b.
className& operator=(const className&);
Here, operator = will take const reference of variable of type className.
You can say in C++, & is polymorphic.

When Does Move Constructor get called?

I'm confused about when a move constructor gets called vs a copy constructor.
I've read the following sources:
Move constructor is not getting called in C++0x
Move semantics and rvalue references in C++11
msdn
All of these sources are either overcomplicated(I just want a simple example) or only show how to write a move constructor, but not how to call it. Ive written a simple problem to be more specific:
const class noConstruct{}NoConstruct;
class a
{
private:
int *Array;
public:
a();
a(noConstruct);
a(const a&);
a& operator=(const a&);
a(a&&);
a& operator=(a&&);
~a();
};
a::a()
{
Array=new int[5]{1,2,3,4,5};
}
a::a(noConstruct Parameter)
{
Array=nullptr;
}
a::a(const a& Old): Array(Old.Array)
{
}
a& a::operator=(const a&Old)
{
delete[] Array;
Array=new int[5];
for (int i=0;i!=5;i++)
{
Array[i]=Old.Array[i];
}
return *this;
}
a::a(a&&Old)
{
Array=Old.Array;
Old.Array=nullptr;
}
a& a::operator=(a&&Old)
{
Array=Old.Array;
Old.Array=nullptr;
return *this;
}
a::~a()
{
delete[] Array;
}
int main()
{
a A(NoConstruct),B(NoConstruct),C;
A=C;
B=C;
}
currently A,B,and C all have different pointer values. I would like A to have a new pointer, B to have C's old pointer, and C to have a null pointer.
somewhat off topic, but If one could suggest a documentation where i could learn about these new features in detail i would be grateful and would probably not need to ask many more questions.
A move constructor is called:
when an object initializer is std::move(something)
when an object initializer is std::forward<T>(something) and T is not an lvalue reference type (useful in template programming for "perfect forwarding")
when an object initializer is a temporary and the compiler doesn't eliminate the copy/move entirely
when returning a function-local class object by value and the compiler doesn't eliminate the copy/move entirely
when throwing a function-local class object and the compiler doesn't eliminate the copy/move entirely
This is not a complete list. Note that an "object initializer" can be a function argument, if the parameter has a class type (not reference).
a RetByValue() {
a obj;
return obj; // Might call move ctor, or no ctor.
}
void TakeByValue(a);
int main() {
a a1;
a a2 = a1; // copy ctor
a a3 = std::move(a1); // move ctor
TakeByValue(std::move(a2)); // Might call move ctor, or no ctor.
a a4 = RetByValue(); // Might call move ctor, or no ctor.
a1 = RetByValue(); // Calls move assignment, a::operator=(a&&)
}
First of all, your copy constructor is broken. Both the copied from and copied to objects will point to the same Array and will both try to delete[] it when they go out of scope, resulting in undefined behavior. To fix it, make a copy of the array.
a::a(const a& Old): Array(new int[5])
{
for( size_t i = 0; i < 5; ++i ) {
Array[i] = Old.Array[i];
}
}
Now, move assignment is not being performed as you want it to be, because both assignment statements are assigning from lvalues, instead of using rvalues. For moves to be performed, you must be moving from an rvalue, or it must be a context where an lvalue can be considered to be an rvalue (such as the return statement of a function).
To get the desired effect use std::move to create an rvalue reference.
A=C; // A will now contain a copy of C
B=std::move(C); // Calls the move assignment operator
Remember that copy elision could occur. If you disable it by passing the -fno-elide-constructors flag to the compiler your constructor might get executed.
You can read about it here: https://www.geeksforgeeks.org/copy-elision-in-c/
Answers above do not give a 'natural' example when a move constructor is called. I found this way to call move constructor without std::move (and without suppressing copy elision by -fno-elide-constructors):
a foo(a a0) {
return a0; // move ctor is called
}
a a1 = foo(a());

Why a reference is returned in an assignment operator overload?

I read that, reference is returned from a overloaded assignment operator to enable operator chaining. But without that return also, operator chaining seems to work.
Can someone shed some light on this?
class A
{
public:
int x,y;
char* str;
//Default Constructor
A(){}
//Constructor
A(int a, int b, char* s){
cout<<"initialising\n";
x = a;
y = b;
str = new char[10];
str = s;
}
//Destructor
~A(){}
//Overloaded assignment operator
const A& operator=(const A& obj)
{
cout<<"Invoking Assignment Operator\n";
x = obj.x;
y = obj.y;
str = new char[10];
str = obj.str;
//return *this;
}
};
ostream& operator<<(ostream& os, const A& obj)
{
os <<"X="<< obj.x<<" Y="<<obj.y<<" Str="<<obj.str<<"\n";
return os;
}
int main()
{
A c(3,4,"Object C");
cout<<c;
A d, e, f;
d = e = f = c; //Assignment operator invoked 3 times
cout<<e;
}
Output:
initialising
X=3 Y=4 Str=Object C
Invoking Assignment Operator
Invoking Assignment Operator
Invoking Assignment Operator
X=3 Y=4 Str=Object C
You're running into undefined behavior because the return type expected from operator = is const A& and you're not returning anything.
It's just unlucky that it works for you. (yes, unlucky, because undefined behavior that appears to work is the worst)
I get a compile error in MSVS, and ideone.com runs into a runtime error.
http://ideone.com/xTDb6
This rule originated from code something like this:
struct Foo {
Foo& copy(const Foo& x) {
return (*this = x);
}
};
At that time, two things were different about C++:
The compiler-generated operator= returned an rvalue by default, and
The compiler allowed a non-const reference to bind to a temporary.
The code above was intended to be equivalent to:
*this = x;
return *this;
But, it wasn't -- since operator= returned an rvalue, the compiler generated a temporary to hold the result of the assignment, then since the function returned a reference, it returned a reference to that temporary. Then, of course, things went badly in a hurry, because you now had a dangling reference to a temporary that was destroyed at the end of the full expression in which it was created. In short, a class case of returning a reference to a local -- except that it took quite a bit of analysis to realize that the local was being generated at all, not to mention a reference to it being returned.
If you define your operator= to return a value instead of a reference, it's going to have to generate a temporary, just like the compiler did in the code above. I haven't thought through the rest in detail to figure out whether the other changes in the current language would be enough to protect you in a case like this, but my immediate reaction is that you're about halfway to reconstituting that ancient bug, so unless you have absolutely no choice in the matter, I'd stay well away.