C++ post-increment: objects vs primitive types

This happens because when overloaded operators are defined as member functions, they follow some semantics which are more related to calling a member function, not to the behavior of the built-in operator. Note that by default, if we declare a non-static member function like:

class X {
public:
    void f();
    X g();
};

then we can call it on both lvalue and rvalue class type expressions:

X().f();   // okay, the X object is prvalue
X x;
x.f();     // okay, the X object is lvalue
x.g().f(); // also okay, x.g() is prvalue

When overload resolution for an operator expression selects a member function, the expression is changed to be just a call to that member function, so it follows the same rules:

++A(); // okay, transformed to A().operator++(), called on prvalue
A a;
++a;   // okay, transformed to a.operator++(), called on lvalue
++a++; // also technically okay, transformed to a.operator++(0).operator++(),
       // a.operator++(0) is a prvalue.

This sort of non-equivalence between built-in operators and overloaded operators also happens with the left subexpression of assignment: the pointless statement std::string() = std::string(); is legal, but the statement int() = int(); is not legal.

But you noted in a comment "I want to design a class that prevents ++a++". There are at least two ways to do that.

First, you could use a non-member operator instead of a member. Most overloaded operators can be implemented as either a member or non-member, where the class type needs to be added as an additional first parameter type of the non-member function. For example, if a has class type, the expression ++a will attempt to find a function as if it were a.operator++() and also a function as if it were operator++(a); and the expression a++ will look for functions for the expressions a.operator++(0) or operator++(a, 0).

(This pattern of trying both ways does not apply to functions named operator=, operator(), operator[], or operator->, because they may only be defined as non-static member functions, never as non-members. Functions named operator new, operator new[], operator delete, or operator delete[], plus user-defined literal functions whose names start like operator "", follow entirely different sets of rules.)

And when the class argument matches a real function parameter, instead of the "implicit object parameter" of a non-static member function, the type of reference used in the parameter, if any, controls as usual whether an argument can be an lvalue, rvalue, or either.

class B {
public:
    // Both increment operators are valid only on lvalues.
    friend B& operator++(B& b) {
        // Some internal increment logic.
        return b;
    }
    friend B operator++(B& b, int) {
        B temp(b);
        ++temp;
        return temp;
    }
};

void test_B() {
    ++B(); // Error: Tried operator++(B()), can't pass
           // rvalue B() to B& parameter
    B b;
    ++b;   // Okay: Transformed to operator++(b), b is lvalue
    ++b++; // Error: Tried operator++(operator++(b,0)), but
           // operator++(b,0) is prvalue and can't pass to B& parameter
}

Another way is to add ref-qualifiers to member functions, which were added to the language in the C++11 version as a specific way of controlling whether a member function's implicit object argument must be an lvalue or rvalue:

class C {
public:
    C& operator++() & {
        // Some internal increment logic.
        return *this;
    }
    C operator++(int) & {
        C temp(*this);
        ++temp;
        return temp;
    }
};

Notice the & between the parameter list and the start of the body. This restricts the function to only accept an lvalue of type C (or something that implicitly converts to a C& reference) as the implicit object argument, similarly to how a const in the same spot allows the implicit object argument to have type const C. If you wanted a function to require an lvalue but allow that lvalue to optionally be const, the const comes before the ref-qualifier: void f() const &;

void test_C() {
    ++C(); // Error: Tried C().operator++(), doesn't allow rvalue C()
           // as implicit object parameter
    C c;
    ++c;   // Okay: Transformed to c.operator++(), c is lvalue
    ++c++; // Error: Tried c.operator++(0).operator++(), but
           // c.operator++(0) is prvalue, not allowed as implicit object
           // parameter of operator++().
}

To get operator= to act more like it does for a scalar type, we can't use a non-member function, because the language only allows member operator= declarations, but the ref-qualifier will similarly work. You're even allowed to use the = default; syntax to have the compiler generate the body, even though the function isn't declared in exactly the same way an implicitly-declared assignment function would have been.

class D {
public:
    D() = default;
    D(const D&) = default;
    D(D&&) = default;
    D& operator=(const D&) & = default;
    D& operator=(D&&) & = default;
};

void test_D() {
    D() = D(); // Error: implicit object argument (left-hand side) must
               // be an lvalue
}

It … just is. There are a few constraints that apply only to primitive types and not class types (well, you've found the most obvious one!).

It is largely because operators for built-in types are one thing, whereas for classes they are just member functions in disguise and therefore a completely different beast.

Is this confusing? I don't know; maybe.

Is there a really compelling reason for it? I don't know; possibly not. There's a certain inertia with primitive types: why change something that was in C just because you're introducing classes? What is the benefit of permitting this? On the other hand, would it not be overly strict to ban it for classes, whose implementation of operator++ could do something that, as the language designer, you haven't thought of?