squeevee
squeevee

Reputation: 161

C++ why is it allowed to assign to a reference to an abstract class?

Suppose I have an abstract base class Abstract and a concrete class Concrete which is derived from Abstract. The following code is allowed:

Concrete c1;
Concrete c2;
Abstract& a(c1);
a = c2;

A more involved demo of this shows that such an assignment results in data slicing:

#include <iostream>

class Abstract {
public:
    virtual void f() const = 0;
  
protected:
    Abstract(int data) : _base_data(data) {}
    int _base_data;
};

class Concrete : public Abstract {
public:
    Concrete(int data) : Abstract(data), _derived_data(data) {}
    void f() const override { std::cout << _base_data << " " << _derived_data << std::endl; }
    
private:
    int _derived_data;
};

int main()
{
  Concrete c1(1);
  std::cout << "c1: ";
  c1.f();
  
  Concrete c2(2);
  std::cout << "c2: ";
  c2.f();
  
  Abstract& a(c1);
  std::cout << "a: ";
  a.f();
  
  a = c2;
  std::cout << "a (post-assignment): ";
  a.f();
}

// Output (GCC and Clang):
// c1: 1 1
// c2: 2 2
// a: 1 1
// a (post-assignment): 2 1

If this were with concrete derived and base classes, I would have assumed as much. But assignment (to me) seems closely related to instantiation, and Abstract can't be instantiated, so I erroneously assumed it couldn't be assigned to, either.

I can't think of a scenario where invoking the implicit assignment operator of an abstract class by reference is a valid thing to do that would result in expected behavior. Except, maybe as an implicit part of a derived class' implicit assignment operator, in which case it's by value and not by reference (I think).

If Abstract had no data members, then I think nothing would happen, and also no warning or error would be present. This seems like a specific and apparent enough case that some kind of measure could be in place from the language, but I don't even get a warning from Clang.

So is this a backwards compatibility thing? Maybe a compiler implementation thing? Is there an edge case that I'm not considering? Why is there not a rule forbidding this?

Upvotes: 0

Views: 809

Answers (3)

user108496
user108496

Reputation: 1

Remember, as a compiled language, it ultimately points somewhere (even if that turns out to be nullptr or 0). Remember, too, that, ultimately, what you're doing is type casting, and C doesn't require you to cast, and you can straight up treat a void* as whatever you want within the language if you know what you're doing (and C++ comes from C).

As for why I might want to do this, imagine that I have a game with collision detection. I might have a function that goes through all objects to determine if they connected with the player or not. Ultimately, all derived classes have to have a defined "check_if_touched_player()" or whatever I call it, so it's safe to rely on it. That's why it's declared virtual. The danger is if you try to use something that's specific to a class that isn't present, which virtual will not allow. I would argue the point that this is the entire reason behind virtual classes: to be able to make lists of dervied classes and "simply have them work" from an OOP perspective without necessarily knowing the details of the base class (after when they were first declared and assigned).

But, notice, it's not actually crashing in your example. The -S output from GCC should assist in rubber duck debugging what's really going on.

//Intel syntax because i hate GAS: -masm=intel
//this is a.f();
        mov     eax, DWORD PTR [ebp-12] //Get the abstract class data that just got declared
        mov     eax, DWORD PTR [eax] //Throw the reference in eax register
        mov     eax, DWORD PTR [eax] //get the address of the overrided function
        sub     esp, 12
        push    DWORD PTR [ebp-12]
        call    eax //Actually call the function
        add     esp, 16 //Unclobber the stack

Hold the individual analysis of each line of assembly with a grain of salt, because completely unoptimized 32bit x86 assembly from GCC is a total pain to read. The important bit is the calling of a register (which means a variable location with a pointer presumably stored in it) rather than an specific, static address.

It's calling data that was stored. In other words, because you used virtual/override, it actually stores pointers to the functions, so as to actually pick the right one. A you can see, though, it went down a pretty long chain. The good news is this chain can be optimized (a little) using optimization flags, but keep in mind that his useful feature comes at that extra memory cost.

EDIT: This is also why you assign the virtual function as 0 (nullptr), so it crashes properly if somehow that data doesn't actually get initialized.

Upvotes: 0

Remy Lebeau
Remy Lebeau

Reputation: 595827

Given a class A, its assignment operator will assign only the A data members of one A object to another A object.

Given a class B that derives from A, a B object is also an A object.

If you have an A& reference (or an A* pointer) to a B object (which is perfectly valid, and required for polymorphism to work properly), the reference/pointer refers to the A portion of the B class, and so only the A members of the B object are accessible via that reference/pointer (to access the B members, you would need to type-cast the reference/pointer to B&/B* first).

A bound reference is just an alias to an object, so given a bound A& reference to a B object:

  • assigning an A object to the A& reference will assign only to the A members of the B object.

  • assigning the A& reference to another A object will assign only from the A members of the B object to the A members of the other object.

This is perfectly valid and legal, albeit not commonly used (outside of B's own assignment operator, like you mentioned). Assignments via base classes is rarely used in polymorphic types. Derived class assignment operators or copy/move constructors are typically used instead.

Upvotes: 1

Mark Ransom
Mark Ransom

Reputation: 308130

The compiler will auto-create copy constructors and operator= for every class you define, unless you explicitly tell it not to. Doesn't matter if they're abstract.

class Abstract {
    Abstract& operator=(const Abstract& a) = delete;
};

Upvotes: 1

Related Questions