Does it make any sense to define operator< as noexcept?
Asked Answered
M

2

7

I know that it makes perfect sense to define e.g. move constructor as noexcept (if possible) and I think I understand the effect of that.

But I have never seen a similar discussion about operator<() of a type used e.g. in std::set<>.

Does also non-throwing comparator have some (potential) optimizing effect (when used in std::set<>, std::map<>, std::sort() and similar)?

Massingill answered 10/7, 2024 at 11:44 Comment(2)
What if (hypothetically) the operator< is used in the move constructor? I have never seen anything bad happen from noexcept on functions that obviously cannot throw.Espresso
The "(if possible)" is interesting. If your move constructor can't be noexcept, something's wrong.Shellback
B
11

A move constructor is somewhat special, because there is an obvious fallback. In a situation where you cannot allow a move to throw an exception you can call a noexcept move constructor and when the move constructor is not noexcept you can fallback to a copy. Hence, declaring the move constructor that does not throw exceptions as noexcept is a potential optimization.


For example std::vector::push_back does try to give a strong exception guarantee (from cppreference):

If an exception is thrown (which can be due to Allocator::allocate() or element copy/move constructor/assignment), this function has no effect (strong exception guarantee).

However, since C++11:

If T's move constructor is not noexcept and T is not CopyInsertable into *this, vector will use the throwing move constructor. If it throws, the guarantee is waived and the effects are unspecified.

Remember that pushing an element may require reallocations, ie to copy/move all elements to different memory location.

The above means: If the type is CopyInsertable, ie one has a choice between copying and moving, the vector will move them when the move constructor is noexcept. In this case you can gain performance by declaring the move constructor noexcept.

When the element type cannot be copied the move constuctor is used in any case (and the vector relaxes its exception guarantee, thanks to François Andrieux for making me aware of insert with similar balancing of exception safety vs performance). In this case what you gain by a noexcept move constructor is a stronger exception guarantee.


std::vector::push_back and similar examples is why there is so much discussion about declaring a move constructor noexcept.

For < there is no such obvious fallback that would be more expensive when < is not noexcept. Either you can call < or you can't.

Otherwise < has the same advantages as any other noexcept method, that the compiler knows it does never throw (and if it does std::terminate is called, but the usual stack unwinding does not necessarily take place).

Biconvex answered 10/7, 2024 at 11:55 Comment(4)
noexcept move is also important for non-copyable types. Handling objects with throwing move constructors can make it very difficult to provide decent exception guaranties. For example std::vector throws out the strong exception guarantee for insertion in this case.Tocsin
@FrançoisAndrieux interersting. Yes, the thing about not using a non-noexcept move constructor is about exception guarantee. I'll try to mention that more explicitly...Biconvex
I recently ran into a bug due to marking a move constructor noexcept. The bug being the (buggy) move constructor which a dev had marked as noexcept (in order to get the strong exception guarantee of std::vector) could throw an exception. The upshot is: make sure your functions marked noexcept are indeed not potentially throwing exceptions. I know it seems obvious, but after running into that problem, a paranoia scrub through the code uncovered 100s of noexcept marked functions that the unhappy path-less-traveled could throw exceptions. Caveat emptor.Hagberry
@Hagberry no, I dont think it is obvious. I was considering to add a note to the answer that one shouldnt add noexcept merely to gain speed. My mantra is always "make code express intent and if you do that right, performance will follow". Getting the wrong result very fast is totally useless. Beginners underestimate that often. And generally it takes 0 time to get a wrong result, hence its not practical to start from something fast but wrong and try to improve it. On the other hand, something that is correct is much simpler to be made more perforamant than something that has bugs.Biconvex
S
4

In addition to the other answer, let me note another optimization that is somewhat closer to noexcept comparator than to a noexcept move constructor.

Let's take a look at how unordered containers, like std::unordered_set, are implemented in libstdc++. These containers are node-based, and in pseudo code the internal node class definition looks like this:

template<class Key>
struct node {
   Key key;
  [size_t hash;]
};

Here the optional second member is present when is_fast_hash<Hash> && is_nothrow_invocable<Hash, Key> is false. (In real code this is implemented using template specialization).

is_fast_hash is there for performance reasons and is an internal type trait that checks whether a hasher is "fast" in some sense (fast for ints, slow for strings, etc.). A user-provided hash is fast by default.

The second type trait is needed because some operations must not throw exceptions. For example, std::unordered_set::erase() that takes an iterator must not throw, but it still has to compute hashes internally.

If is_nothrow_invocable<Hash, Key> is false, then node will be bigger by at least sizeof(std::size_t) bytes. By marking your hash function with noexcept you'll make node smaller, but at the same time your hasher might be invoked more often. Whether this will result in faster or slower code in practice is of course a matter of profiling.

Sinclair answered 10/7, 2024 at 14:7 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.