lmi
[Top][All Lists]
Advanced

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

Re: [lmi] Detecting whether move semantics actually take place


From: Greg Chicares
Subject: Re: [lmi] Detecting whether move semantics actually take place
Date: Sun, 31 Jul 2022 21:16:15 +0000
User-agent: Mozilla/5.0 (X11; Linux x86_64; rv:91.0) Gecko/20100101 Thunderbird/91.8.0

On 7/31/22 12:41, Vadim Zeitlin wrote:
>  Sorry, I'd like to slightly amend what I wrote last night:
> 
> On Sun, 31 Jul 2022 03:14:09 +0200 I wrote:
> 
> Me> On Sun, 31 Jul 2022 00:41:37 +0000 Greg Chicares 
> <gchicares@sbcglobal.net> wrote:
> Me> 
> Me> GC> In the modified example below, struct 'non_movable' is constructible 
> and
> Me> GC> assignable from 'non_movable&&' by using copy semantics, but not, 
> AIUI,
> Me> GC> by using move semantics.
> Me> 
> Me>  It does use move semantics.
> 
>  It still does...

Thanks, this demonstrated a flaw in my conceptual model. I had thought
that, when a most-derived class (like the examples in this thread)
has implicitly-declared move and copy operations, and has a base class
lacking move operations, then we'd just get a copy instead of a move.
But there are different degrees of "lacking" move operations:

(A) Explicitly declared as deleted: participates in overload resolution
   - "deleted" is unfortunate terminology: it's not as though it
     potentially existed but wasn't allowed to be born--no, it's
     present, as a compile-time error trap
   - if it's chosen by overload resolution, the program is ill-formed

(B) Not declared: doesn't participate in overload resolution
   - as though it had never been conceived, much less born
   - it's a funky rule; I guess they needed it for c++98 compatibility

I had thought that either of these would act as an impediment to move
semantics, causing the most-derived class's (explicitly defaulted)
move operations to be defined as deleted. But they don't both impede
that; only the first one (= delete) does.

Thus, my top-level (mis)understanding was that the most-derived class's
move operations look for impediments
 - const or reference members (spoil both move and copy assignment)
 - non-movable data members
 - non-movable base classes anywhere in the inheritance chain
and, if there's any impediment, moving is defined as deleted and only
copying is allowed. But I had imagined that "non-movable" was a unitary
concept--either you have move members, or you don't--whereas now it
seems to me that it's a tri-state property:
 - move forbidden (= delete);
 - move declared, whether explicitly or implicitly, and not
   defined as deleted;
 - move "not declared", as in the green boxes in the chart at the
   bottom of this page:
     https://howardhinnant.github.io/classdecl.html
so I'm going to have to watch his video
     https://www.youtube.com/watch?v=vLinb2fgkHk
again and try to grok this "defaulted | deleted | inhibited"
trichotomy. [Edit: It becomes clearer to me below.]

I'm guessing the answer may be that deletion is infectious, but inhibition
is not. Thus, using the example you gave, when the most-derived class is
initialized from an rvalue reference, the reasoning is like this:
  - int data member: just copy it because that's cheapest?
  - move_detector member: it's explicitly moveable, so move it
  - base class 'base': move operations are "not declared", which I guess
      is synonymous with "inhibited", so that'll be [hypothesis] copied?
Investigation calls for some tooling, which you provide...

> Me>  I can prove this experimentally:
> 
> .. but I think the demonstration can be improved. Instead of manually
> defining copy and move ctors, which could be different from what the
> compiler does by default, let's rely on the default-generated ctors:

Add a mixin class to print which {move|copy} function is called: thanks.

Here, I believe you meant the copy ctor to print "copied":

> struct move_detector {
>     explicit move_detector(int n) : n_{n} {}
>     move_detector(move_detector const& x) : n_{x.n_} { printf("[%d] moved\n", 
> n_); }
                                                                      ^^^^^
                                                                      copied
>     move_detector(move_detector&& x) : n_{x.n_} { printf("[%d] moved\n", n_); 
> }
> 
>     int n_;
> };

[with that change, it still prints "moved" in your example]

Let's use that to test the hypothesis above. I'll modify your example,
transplanting the move_detector member to the base class, and changing the
prime number so there's no question whether I'm running the new 'a.out':

---------------------------------- >8 --------------------------------------
#include <stdio.h>
#include <utility>

struct move_detector {
    explicit move_detector(int n) : n_{n} {}
    move_detector(move_detector const& x) : n_{x.n_}
        { printf("[%d] copied\n", n_); }
    move_detector(move_detector&& x) : n_{x.n_}
        { printf("[%d] moved\n", n_); }

    int n_;
};

struct base {
    ~base() = default;

    move_detector value{19};
};

struct non_movable : base {
    non_movable() = default;
    non_movable(non_movable const&) = default;
    non_movable(non_movable&&) = default;
};

int main() {
    non_movable x;
    return non_movable{std::move(x)}.value.n_;
}
---------------------------------- >8 --------------------------------------

I predict that move_detector will be copied, so 'a.out' will print
"[19] copied" and return 19. Let's see:

/opt/lmi/src/lmi[0]$clang -Wall -std=c++20 eraseme.cpp && ./a.out || echo $?
[19] copied
19

Hypothesis confirmed, or, at least, not disproven.

This suggests to me that the {copy|move} duality is more complex than I had
imagined. I had thought that C++ strongly preferred copying (even if only
for C++98 compatibility), and would use move semantics only if every
subobject is moveable, but revert to copy semantics at the slightest whiff
of a lack of moveability--and in particular I thought that the most-derived
class would perceive non-moveability in a base class and itself revert to
copying. (Maybe I got that impression by reading countless online questions
like "Why did initialization from X&& work when there's no move ctor?", to
which the answer is usually "It matched X(X const&), so it got copied".)
But in this case, the most-derived class is actually moved, and its
non-moveable base is copied.

Can it flip back and forth both ways? Let's try:

---------------------------------- >8 --------------------------------------
#include <stdio.h>
#include <utility>

struct move_detector {
    explicit move_detector(int n) : n_{n} {}
    move_detector(move_detector const& x) : n_{x.n_}
        { printf("[%d] copied\n", n_); }
    move_detector(move_detector&& x) : n_{x.n_}
        { printf("[%d] moved\n", n_); }

    int n_;
};

struct base0 {
    base0() = default;
    base0(base0 const&) = delete;
    base0(base0&&) = default;
    move_detector value{13};
};

struct base1 : base0 {
    ~base1() = default;
};

struct non_movable : base1 {
    non_movable() = default;
    non_movable(non_movable const&) = default;
    non_movable(non_movable&&) = default;
};

int main() {
    non_movable x;
    return non_movable{std::move(x)}.value.n_;
}
---------------------------------- >8 --------------------------------------

Wow.

Skip gcc and just use clang here. This is why the
  https://howardhinnant.github.io/classdecl.html
chart needs a fifth color (gray). Above, I thought I had discovered
a tri-state property for {move|copy}, but there are five states,
not all of which apply to both moving and copying.

I had guessed that C++ had a deep-seated preference for copying, but
no such notion is needed to explain this. The preference isn't deep
in the language; it's right there, on the chart. It's not really a
preference at all--it's just the asymmetry between two rules:
 - user-declared copy --> move not declared (not deleted)
 - user-declared move --> copy deleted (not just non-declared)
It's not deep, it's shallow--it's an artifact of these complicated
backward-compatibility rules. I suppose they felt they couldn't do
anything more severe than deprecating the old implicit-copy rules.

I just wish clang or gcc had a switch to pretend that the Committee
had been more bold and overturned the implicit-copy rules.

>  Also, adding move (and default, to avoid unrelated errors) ctors to "base"
> prevents the default-generated move ctor from compiling (because it calls
> the base class move ctor) and so makes the derived class non-movable:
> again, as you'd expect it to do.

Let's try that, too, to make sure I understand your intention:

---------------------------------- >8 --------------------------------------
#include <stdio.h>
#include <utility>

struct move_detector {
    explicit move_detector(int n) : n_{n} {}
    move_detector(move_detector const& x) : n_{x.n_}
        { printf("[%d] copied\n", n_); }
    move_detector(move_detector&& x) : n_{x.n_}
        { printf("[%d] moved\n", n_); }

    int n_;
};

struct base {
    ~base() = default;
    base() = default;
    base(base&&) = default;
};

struct non_movable : base {
    non_movable() = default;
    non_movable(non_movable const&) = default;
    non_movable(non_movable&&) = default;

    move_detector value{23};
};

int main() {
    non_movable x;
    return non_movable{std::move(x)}.value.n_;
}
---------------------------------- >8 --------------------------------------

$clang -Wall -std=c++20 eraseme.cpp && ./a.out || echo $?
eraseme.cpp:22:5: warning: explicitly defaulted copy constructor is implicitly 
deleted [-Wdefaulted-function-deleted]
    non_movable(non_movable const&) = default;
    ^
eraseme.cpp:20:22: note: copy constructor of 'non_movable' is implicitly 
deleted because base class 'base' has a deleted copy constructor
struct non_movable : base {
                     ^
eraseme.cpp:17:5: note: copy constructor is implicitly deleted because 'base' 
has a user-declared move constructor
    base(base&&) = default;
    ^
1 warning generated.
[23] moved
23

Again, it's a gray-colored box on that chart; now that I understand that,
clang's diagnostics are very clear.

>  So AFAICS everything works fine here and clang -Wdefaulted-function-deleted
> is given when you'd expect it to be, i.e. when the defaulted function is
> actually deleted.

Yes. It's not a manifest error, yet, but it's better to get an early warning,
which gcc doesn't give (at least not with only '-Wall'):

$g++ -Wall -std=c++20 eraseme.cpp && ./a.out || echo $?  
[23] moved
23

Thanks for helping me work through this.


reply via email to

[Prev in Thread] Current Thread [Next in Thread]