14

Note: Originally asked by Matt Mcnabb as a comment on Why can swapping standard library containers be problematic in C++11 (involving allocators)?.


The Standard (N3797) says that if progagate_on_container_swap inside an Allocator is std::false_type it will yield undefined behaviour if the two allocators involved doesn't compare equal.

  • Why would the Standard allow such construct when it seems more than dangerous?

23.2.1p9 General Container Requirements [container.requirements.general]

If allocator_traits<allocator_type>::propagate_on_container_swap::value is true, then the allocators of a and b shall also be exchanged using an unqalified call to non-member swap. Otherwise, they shall not be swapped, and the behavior is undefined unless a.get_allocator() == b.get_allocator().

Community
  • 1
  • 1
Filip Roséen - refp
  • 62,493
  • 20
  • 150
  • 196

2 Answers2

12

I can think of a few real-life scenarios where the construct allowed by the Standard both makes sense, and is required, however; I'll first try to answer this question from a broader perspective, not involving any specific problem.


THE EXPLANATION

Allocators are this magical things responsible for allocating, constructing, destructing, and deallocating memory and entities. Since C++11, when stateful allocators came into play, an allocator can do much more than previously, but it all boils down to the previously mentioned four operations.

Allocators have loads of requirements, one of them being that a1 == a2 (where a1 and a2 are allocators of the same type) must yield true only if memory allocated by one can be deallocated by the other [1].

The above requirement of operator== means that two allocators comparing equal can do things differently, as long as they still have a mutual understanding of how memory is allocated.

The above is why the Standard allows propagate_on_container_* to be equal to std::false_type; we might want to change the contents of two containers which allocators have the same deallocation behavior, but leave the other behavior (not related to basic memory management) behind.


[1] as stated in [allocator.requirements]p2 (table 28)


THE (SILLY) STORY

Imagine that we have an Allocator named Watericator, it gathers water upon requested allocation, and hands it to the requested container.

Watericator is a stateful Allocator, and upon constructing our instance we can choose two modes;

  1. employ Eric, who fetches water down at the fresh water spring, while also measuring (and reporting) water level and purity.

  2. employ Adam, who uses the tap out in the backyard and doesn't care anything about logging. Adam is a lot faster than Eric.


No matter where the water comes from we always dispose of it in the same way; by watering our plants. Even if we have one instance where Eric is supplying us water (ie. memory), and another where Adam is using the tap, both Watericators compare equal as far as operator== is concerned.

Allocations done by one can be deallocated by the other.


The above might be a silly simile, but imagine we have an allocator which does logging upon every allocation, and we uses this on a container somewhere in our code that interests us; we later want to move the elements out from this container into another one.. but we are no longer interested in all that logging.

Without stateful allocators, and the option to turn propagate_on_container_* off, we would be forced to either 1) copy every element involved 2) be stuck with that (no longer required) logging.

Filip Roséen - refp
  • 62,493
  • 20
  • 150
  • 196
  • 1
    I so appreciate examples that reference real world things that can be visualized (even if silly), rather than the meaningless `A`, `B`, or `Foo`. Thank you! – Emile Cormier Jan 29 '23 at 01:45
0

It is not so much that the Standard allows propagate_on_container_swap to cause Undefined Behavior, but that the Standard exposes Undefined Behavior via this value!


A simple example is to consider a scoped allocator, which allocates memory from a local pool, and which said pool is deleted when the allocator goes out of scope:

template <typename T>
class scoped_allocator;

Now, let us use it:

int main() {
    using scoped = scoped_allocator<int>;

    scoped outer_alloc;
    std::vector<int, scoped> outer{outer_alloc};

    outer.push_back(3);

    {
        scoped inner_alloc;
        std::vector<int, scoped> inner{inner_alloc};

        inner.push_back(5);

        swap(outer, inner); // Undefined Behavior: loading...
    }

    // inner_allocator is dead, but "outer" refers to its memory
}
Matthieu M.
  • 287,565
  • 48
  • 449
  • 722
  • I think it would have been easy to either define that such incorrect use of swap simply doesn't compile. Or, even better, that it compiles and works correctly - by copying stuff around instead of simply swapping pointers. So the question is: why wasn't it done like that? – Paul Groke Mar 11 '19 at 11:43
  • @PaulGroke: Copying stuff around is usually impossible in C++, because the address of an object is observable, so there is a difference between original and copy (a sharp difference with languages with a compacting GC). I think the standard just punted here... after all, UB gives the implementer the latitude to offer a more useful behavior, such as not compiling, throwing an exception, etc... – Matthieu M. Mar 11 '19 at 13:52
  • Ah, yes, thank you! I didn't think of the fact that many programs will probably rely on the address staying the same after a swap. Also, after asking the question, I realized that not compiling also isn't an option. It would prevent the use of swap in scenarios with defined behavior (=when the two allocator instances are compatible i.e. when they compare equal). (Not compiling *would* have been an option if the standard said so, but as it is a conforming implementation has to allow it to compile. So I guess that just leaves aborting or throwing.) – Paul Groke Mar 11 '19 at 21:54