The descendants of std::exception do own data. For example std::runtime_error owns its what() message. And that message is dynamically allocated because it can be an arbitrarily long message.
However, the copy constructor is marked noexcept (implicitly) because the std::exception copy constructor is noexcept.
#include <stdexcept>
#include <type_traits>
int
main()
{
static_assert(std::is_nothrow_copy_constructible<std::runtime_error>{});
}
The only way for a class to own a dynamically allocated message, and have a noexcept copy constructor, is for that ownership to be shared (reference counted). So std::runtime_error is essentially a const, reference counted string.
There was simply no motivation to give these types a move constructor because the copy constructor is not only already very fast, but the exceptional path of program is only executed in exceptional circumstances. About the only thing a move constructor for std::runtime_error could do is eliminate an atomic increment/decrement. And no one cared.
Wouldn't having a move-constructor allow cheaply catching by value and thus simplifying the guidelines?
You can already cheaply catch by value. But the guideline exists because exceptions are often part of an inheritance hierarchy, and catching by value would slice the exception:
#include <exception>
#include <iostream>
#include <stdexcept>
int
main()
{
try
{
throw std::runtime_error("my message");
}
catch (std::exception e)
{
std::cout << e.what() << '\n';
}
}
Output (for me):
std::exception