Recently, I discovered that std::ranges
prohibits the creation of dangling iterators and provides an owning_view
to take ownership of temporaries. Digging into the details led me to the ref-qualified memeber functions which can be used to make code safer and/or more performant.
Article updates (2023-05-19)
- mistakenly said that lvalue-overload can't match rvalue object: it can.
- address perfomance considerations: now UnsafeReference is a wrapper around rvalue reference;
- added a C++23 solution making use of 'deducing *this'
- added a list of recommended resources
Intro (the source of my excitement)
Consider the following code that creates a temporary array using get_array_by_value
, then uses the temporary when safe, rejects compilation when not safe, and takes ownership of the temporary when needed.
Although std::ranges
is not the primary focus of this article, it’s worth to briefly touch on it before moving on to the main topic. For those who are new to std::ranges
, a brief explanation will follow below.
#include <algorithm>
#include <array>
#include <ranges>
#include <type_traits>
#include <iostream>
#include <numeric>
int main()
{
auto get_array_by_value = [] { return std::array{0, 1, 0, 1}; };
// (1) a search for the max element in the temporary
auto dangling_iter = std::ranges::max_element(get_array_by_value());
static_assert(std::is_same_v<std::ranges::dangling, decltype(dangling_iter)>);
// Compilation error below:
// no match for 'operator*' (operand type is 'std::ranges::dangling')
// std::cout << *dangling_iter << '\n';
// (2) However, the code below works fine:
std::array<int, std::size(get_array_by_value())> copied;
auto copy_result = std::ranges::copy(get_array_by_value(), std::begin(copied));
static_assert(std::is_same_v<std::ranges::dangling, decltype(copy_result.in)>);
// (3) and this is fine, too:
auto isOdd = [](int i){ return 0 != (i % 2);};
auto filtered = get_array_by_value() | std::views::filter(isOdd);
return std::accumulate(std::begin(filtered), std::end(filtered), 0);
}
- Here, we create a temporary array and search for its maximum element. Since the temporary is destroyed at the end of the full-expression1, a trivial implementation might have returned an iterator to an element of the destroyed array. However,
std::ranges
detects this and returns a special type calledstd::ranges::dangling
. This approach accomplishes two things:
std::ranges::max_element
is executed, any side effects are preserved (e.g. stdout output);dangling_iter
is impossible to dereference, preventing access to the destroyed array.
A temporary is valid to be copied from, but the copy but the iterator to the end of the source range is
std::ranges::dangling
, which prevents dereferencing.The temporary is filtered and should be destroyed at the end of the full-expression, but the view takes ownership over it using the owning_view. This means that it is perfectly valid to iterate over the filtered range after the original temporary has been destroyed.
All the magic above may suggest that when accessing the internal state of a large object like RawImageBytes
, or when creating a most-recent-N subrange of the RingBuffer
, we can do better than simply returning a pointer or reference to the internal data buffer and hoping that nobody will accidentally use a dangling pointer.
The problem
Consider the MyRawImage
class that holds large image data. Some computational algorithms may need image’s data as a buffer, so MyRawImage::data()
method is needed to access the whole buffer. Here is the first attempt to implement it:
struct Pixel
{
int r = 0;
int g = 0;
int b = 0;
friend auto operator<=>(const Pixel& a, const Pixel& b) = default;
};
struct Metadata { /* some metadata here, width, height, etc. */ };
class MyRawImage
{
std::vector<Pixel> m_buffer;
Metadata m_metadata;
public:
MyRawImage(std::vector<Pixel> src) : m_buffer(std::move(src)) {}
const Pixel& operator[](int index) const { return m_buffer[index]; }
Pixel& operator[](int index) { return m_buffer[index]; }
const std::vector<Pixel>& data() const { return m_buffer; }
const Metadata& information() const { return m_metadata; }
};
MyRawImage loadImage(int i)
{
return std::vector<Pixel>(i * 100, Pixel {i, i, i});
}
MyRawImage problematic(int i)
{
std::vector<Pixel> filtered;
auto filter = [](Pixel p)
{
p.r = std::min(p.r, 0xFF);
p.g = std::min(p.g, 0xFF);
p.b = std::min(p.b, 0xFF);
return p;
};
// oops: equivalent of `auto&& ps = loadImage(i).data(); for (p : ps) { ... }`
// loadImage() returns a temporary, temporary.data() reference is stored,
// then for-loop iterates over a stored reference to a deleted temporary
for(Pixel p : loadImage(i).data())
filtered.push_back(filter(p));
return filtered;
}
std::vector<Pixel> suboptimal_performance(int i)
{
// can't move from the temporary, even using explicit move,
// because the reference is const
std::vector<Pixel> filtered = loadImage(i).data();
auto filter = [](Pixel p)
{
p.r = std::min(p.r, 0xFF);
p.g = std::min(p.g, 0xFF);
p.b = std::min(p.b, 0xFF);
return p;
};
for(Pixel& p : filtered)
p = filter(p);
return filtered;
}
Pixel fine(int i)
{
auto max = [](auto&& range) -> Pixel
{
return *std::max_element(std::begin(range), std::end(range));
};
// this one is fine: a temporary will be destroyed after the max() calcualtion
return max(loadImage(i).data());
}
int main(int, char**)
{
constexpr static int pattern = 0x12;
constexpr static Pixel pixelPattern = Pixel { pattern, pattern, pattern };
auto isGood = [](const Pixel& p) { return p == pixelPattern; };
Pixel maxPixel = fine(pattern);
assert(maxPixel == pixelPattern);
std::vector<Pixel> pixels = suboptimal_performance(pattern);
assert(pixels.end() == std::ranges::find_if_not(pixels, isGood));
MyRawImage img = problematic(pattern);
assert(img.data().end() == std::ranges::find_if_not(img.data(), isGood));
}
First, there is a performance pitfall: although moving the whole MyRawImage
is possible, moving the data out of temporary is not because the constant lvalue reference prohibits even explicit std::move
.
Disregarding the performance, the code above aims to accomplish the following:
fine()
method ’loads’ image that consists of one hundred pixels with a value of{0x12,0x12,0x12}
and finds the maximum among them.problematic()
method loads similar image and clamps every pixel’s component to the maximum value of 0xFF and ensures that no pixels deviate from the 0x12 pattern.
Program returned: 139
output.s: /app/example.cpp:77: int main(int, char**):
Assertion `img.data().end() == std::ranges::find_if_not(img.data(), isGood)' failed.
I believe that the reader can solve both mathematical problems mentally, but the output above suggests that until C++23 the provided code can’t. Even with C++23, there is a same pitfall with const Bar& lvalue = foo().bar();
: there is no const lvalue reference to the result of foo()
so its lifetime is not extended and the refererence, returned from bar() is dangling. The reader could proceed to the Temporary range expression chapter in the range-based for documentation for mode details, if needed.
The problem arises when obtaining a reference or pointer to a property of a temporary object. Often, it is permissible, but there is a risk of misuse by developers, leading to dangling references and undefined behavior.
A short STL example:
std::string getMessage() { return "I'm a long enough message to avoid SSO"; }
// fine, the string is destroyed at the semicolon
int length = strlen(getMessage().c_str());
// bad, the string has been destroyed at the semicolon below
const char* dangling = getMessage().c_str();
int oops = strlen(dangling);
std::string
content using WinAPI SendMessage
or PostMessage
. While SendMessage
is synchronous and fine, PostMessage
will result in a dangling pointer being stored and used later.I believe we can do better: provide an access to data()
in a way that significantly reduces the likelihood of misuse and the occurrence of dangling references. We’ll try to address the performance considerations as well.
A ref-qualified member function
It could be reasonable to prevent certain member functions from being called on a temporary object. That’s what we could achieve with ref-qualified member functions. This feature is not broadly used, so here is a short description: a member function might have a reference qualifier in the same familiar way as the const qualifier. The correct overload will be chosen based on the type of *this
reference. While member functions can also be const-qualified, it’s important to note that constness is an orthogonal property that does not affect the reference qualifier.
An overload resolution on ref-qualifier is done in the same way as for cv-qualifiers. To better understand how the member function overload works, let’s remember that the member function is considered to have an extra first parameter, called the implicit object parameter, which represents the object for which the member function has been called.
Thus, Foo foo; foo.f()
is similar to Foo::f(foo);
2, where the type of implicit argument is determined by present cv- and ref- qualifiers on the member function declaration. I think the execution result of the sample below will illustrate this much better than I can:
#include <iostream>
struct S
{
const char* foo() const & { return "foo: const & \n"; }
const char* foo() & { return "foo: & \n"; }
const char* foo() const && { return "foo: const && \n"; }
const char* foo() && { return "foo: && \n"; }
static const char* bar(const S&) { return "bar: const & \n"; }
static const char* bar(S&) { return "bar: & \n"; }
static const char* bar(const S&&) { return "bar: const && \n"; }
static const char* bar(S&&) { return "bar: && \n"; }
void cref() const & {};
void l_ref() & {};
void r_ref() && {};
};
int main(int, char**)
{
auto getS = [] -> S { return S(); };
auto getCS = [] -> const S { return S(); };
S s = getS();
const S cs = getS();
std::cout << getS().foo() // foo: &&
<< getCS().foo() // foo: const &&
<< s.foo() // foo: &
<< cs.foo() // foo: const &
<< "-----------\n"
<< S::bar(getS()) // bar: &&
<< S::bar(getCS()) // bar: const &&
<< S::bar(s) // bar: &
<< S::bar(cs) // bar: const &
<< std::endl;
s.cref(); // ok
getS().cref(); // ok, 'const S& self = getS();`
s.l_ref(); // ok
// getS().l_ref(); // error: 'S& self = getS()': 'S' as 'this' argument discards qualifiers [-fpermissive]
// s.r_ref(); // error: 'S&& self = s;' requires explicit cast
getS().r_ref();
}
It’s worth highlighting that it’s rare to encounter a const &&
reference in the wild, so typically, rvalue-overloads should be non-const.
Disable a member function call for rvalues
Having read the cppreference, let’s delete undesired overloads from the MyRawImage
with almost no effort:
class MyRawImage
{
std::vector<Pixel> m_buffer;
Metadata m_metadata;
public:
MyRawImage(std::vector<Pixel> src) : m_buffer(std::move(src)) {}
const Pixel& operator[](int index) const { return m_buffer[index]; }
Pixel& operator[](int index) { return m_buffer[index]; }
const std::vector<Pixel>& data() const & { return m_buffer; }
const std::vector<Pixel>& data() && = delete;
const Metadata& information() const & { return m_metadata; }
const Metadata& information() && = delete;
};
<source>: In function 'MyRawImage problematic(int)':
<source>:54:36: error: use of deleted function 'const std::vector<Pixel>& MyRawImage::data() &&'
54 | for(Pixel p : loadImage(i).data())
| ~~~~~~~~~~~~~~~~~^~
Now, let’s address the compiler’s complaints and examine the result. Currently, the only way to access MyRawImage::data()
is by declaring a MyRawImage variable or using a const reference to extend the temporary lifetime. Therefore, we need to introduce a named variable or a const reference for every call to data
on the temporary.
#include <ranges>
#include <cassert>
#include <vector>
#include <algorithm>
struct Pixel
{
int r = 0;
int g = 0;
int b = 0;
friend auto operator<=>(const Pixel& a, const Pixel& b) = default;
};
struct Metadata { /* some metadata here, width, height, etc. */ };
class MyRawImage
{
std::vector<Pixel> m_buffer;
Metadata m_metadata;
public:
MyRawImage(std::vector<Pixel> src) : m_buffer(std::move(src)) {}
const Pixel& operator[](int index) const { return m_buffer[index]; }
Pixel& operator[](int index) { return m_buffer[index]; }
const std::vector<Pixel>& data() const & { return m_buffer; }
const Metadata& information() const & { return m_metadata; }
const std::vector<Pixel>& data() && = delete;
const Metadata& information() && = delete;
};
MyRawImage loadImage(int i)
{
return std::vector<Pixel>(i * 100, Pixel {i, i, i});
}
MyRawImage was_problematic(int i)
{
std::vector<Pixel> filtered;
auto filter = [](Pixel p)
{
p.r = std::min(p.r, 0xFF);
p.g = std::min(p.g, 0xFF);
p.b = std::min(p.b, 0xFF);
return p;
};
// const lvalue reference extends temporary object lifetime
const MyRawImage& image = loadImage(i);
for(Pixel p : image.data())
filtered.push_back(filter(p));
return filtered;
}
std::vector<Pixel> suboptimal_performance(int i)
{
// Well, at least, now this code begs for optimization
MyRawImage image = loadImage(i); // no problem, thanks to copy-elision
// can't move from the temporary, even using explicit move,
// because the reference is const
std::vector<Pixel> filtered = image.data();
auto filter = [](Pixel p)
{
p.r = std::min(p.r, 0xFF);
p.g = std::min(p.g, 0xFF);
p.b = std::min(p.b, 0xFF);
return p;
};
for(Pixel& p : filtered)
p = filter(p);
return filtered;
}
Pixel was_fine(int i)
{
auto max = [](auto&& range) -> Pixel
{
return *std::max_element(std::begin(range), std::end(range));
};
// this one was fine: a temporary will be destroyed after the max() calcualtion
// return max(loadImage(i).data());
MyRawImage image = loadImage(i);
return max(image.data()); // <-- would be nice to avoid creating a named variable here
}
int main(int, char**)
{
constexpr static int pattern = 0x12;
constexpr static Pixel pixelPattern = Pixel { pattern, pattern, pattern };
auto isGood = [](const Pixel& p) { return p == pixelPattern; };
Pixel maxPixel = was_fine(pattern);
assert(maxPixel == pixelPattern);
std::vector<Pixel> pixels = suboptimal_performance(pattern);
assert(pixels.end() == std::ranges::find_if_not(pixels, isGood));
MyRawImage img = was_problematic(pattern);
assert(img.data().end() == std::ranges::find_if_not(img.data(), isGood));
}
was_problematic
method is now good, since there is no temporary access. It utilizes const lvalue reference lifetime extension: the return value is bound to aconst MyRawImage& image
, thus extending its lifetime to match that of the reference. So thedata()
call is safe;- on the other hand, the
was_fine
method had to introduce an unnecessary lvalue variable for the temporary. While it may not seem like a significant problem, it violates the principle of keeping the scope of variables as small as possible. Ideally, it would have been preferable to maintain the temporary’s lifetime within a single full-expression, but unfortunately, it didn’t work out that way; - and the code inside
suboptimal_performance
definitely looks like a candidate for optimization.
Explicitly enable the member function call on rvalues
The problem is that compiler can’t detect whether the reference to a property of the temporary will outlive the parent object. Technically there are two choices: allow the call on rvalue always or never.
But what if the developer is certain that an rvalue could be used in the specific expression? Well, we can give the ability to state this intention and forcibly allow an rvalue overload call with a little trick.
Let’s go back for a second to std::range
. It does not prohibit creating a view from a temporary but performs a view type selection instead:
- for lvalue, a non-owning
std::ranges::ref_view
is used; - for rvalue, an owning
std::ranges::owning_view
is used to take the ownership of the temporary and convert it to an lvalue; - when returning a possible-dangling iterator to a content of the temporary, return a
std::ranges::dangling
to indicate this.
We could adopt the last approach to our needs: the rvalue overload of data()
could return a special wrapper that should not be implicitly-convertible to an underlying reference type. This approach will require the programmer’s attention and make sure that there is no dangerous access made by oversight.
Here we go:
|
|
The result I could live with
The complete code below:
#include <ranges>
#include <cassert>
#include <vector>
#include <algorithm>
struct Pixel
{
int r = 0;
int g = 0;
int b = 0;
friend auto operator<=>(const Pixel& a, const Pixel& b) = default;
};
struct Metadata { /* some metadata here, width, height, etc. */ };
class MyRawImage
{
std::vector<Pixel> m_buffer;
Metadata m_metadata;
public:
// a wrapper around temporary result of MyRawImage().data() may be
// converted to a temporary 'std::vector<Pixel>' explictly using allow_unsafe().
// Be safe and ensure that it will not outlive its parent MyRawImage.
class UnsafeReference
{
std::vector<Pixel>&& m_buffer;
public:
UnsafeReference(std::vector<Pixel>&& buffer) : m_buffer(std::move(buffer)) {}
UnsafeReference(const UnsafeReference&) = delete;
UnsafeReference(UnsafeReference&&) = delete;
// I would like it to be a free-function rather a member functions,
// to lower the chance that the Intellisence will provide a disservice
// to the developer slipping an unsafe getter by auto-suggestions.
// it's good to require a fair attention here
friend std::vector<Pixel>&& allow_unsafe(UnsafeReference&&);
};
MyRawImage(std::vector<Pixel> src) : m_buffer(std::move(src)) {}
const Pixel& operator[](int index) const { return m_buffer[index]; }
Pixel& operator[](int index) { return m_buffer[index]; }
const std::vector<Pixel>& data() const & { return m_buffer; }
UnsafeReference data() && { return std::move(m_buffer); }
const Metadata& information() const & { return m_metadata; }
const Metadata& information() && = delete;
};
std::vector<Pixel>&& allow_unsafe(MyRawImage::UnsafeReference&& unsafe)
{
// 'unsafe.m_buffer' is lvalue in the function body
return std::move(unsafe.m_buffer);
}
MyRawImage loadImage(int i)
{
return std::vector<Pixel>(i * 100, Pixel {i, i, i});
}
MyRawImage was_problematic(int i)
{
std::vector<Pixel> filtered;
auto filter = [](Pixel p)
{
p.r = std::min(p.r, 0xFF);
p.g = std::min(p.g, 0xFF);
p.b = std::min(p.b, 0xFF);
return p;
};
const MyRawImage& image = loadImage(i); // const lvalue reference extends temporary object lifetime
for(Pixel p : image.data())
filtered.push_back(filter(p));
return filtered;
}
std::vector<Pixel> was_suboptimal(int i)
{
// now it's good:
// move from the temporary, because allow_unsafe returns an rvalue reference
std::vector<Pixel> filtered = allow_unsafe(loadImage(i).data());
auto filter = [](Pixel p)
{
p.r = std::min(p.r, 0xFF);
p.g = std::min(p.g, 0xFF);
p.b = std::min(p.b, 0xFF);
return p;
};
for(Pixel& p : filtered)
p = filter(p);
return filtered;
}
Pixel fine_again(int i)
{
auto max = [](auto&& range) -> Pixel
{
return *std::max_element(std::begin(range), std::end(range));
};
// fine: a temporary will be destroyed after the max() calcualtion
return max(allow_unsafe(loadImage(i).data()));
}
int main(int, char**)
{
constexpr static int pattern = 0x12;
constexpr static Pixel pixelPattern = Pixel { pattern, pattern, pattern };
auto isGood = [](const Pixel& p) { return p == pixelPattern; };
Pixel maxPixel = fine_again(pattern);
assert(maxPixel == pixelPattern);
std::vector<Pixel> pixels = was_suboptimal(pattern);
assert(pixels.end() == std::ranges::find_if_not(pixels, isGood));
MyRawImage img = was_problematic(pattern);
assert(img.data().end() == std::ranges::find_if_not(img.data(), isGood));
}
Deducing ’this'
Well, there should be a place where C++23 is available, developers are careful enough when using references and probably a unicorn is hanging around. Can we do even better?
At least, we could do a mental experiment and imagine such a place.
(if you're there, and if it isn't 2030s around, please, email me - I have to apply for that job)Since C++23 the compiler is able to deduce the type of implicit member function parameter and make it explicit. Literally, it’s like converting the X x; x.foo()
into X x; X::foo(x)
. Among other unobvious abilities, it allows the perfect forwarding of ‘*this’. There is a great overview in the Sy Brand article and a great insight in the Deducing this Patterns - talk on CppCon.
Having read the article we could drop the UnsafeReference
and its explicit allow_unsafe
for significantly cleaner implementation code:
class MyRawImage
{
// ...
template <typename Self>
auto&& data(this Self&& self)
{
return std::forward<Self>(self).m_buffer;
}
// ...
};
MyRawImage::data()
template that captures *this
as auto&& self = *this
conforms to the usual forwarding reference rules: in the return statement, self
is forwarded with exactly the same type as decltype(*this)
was, preserving cv- and ref-qualification.As C++23 allows usage of reference to the temporary’s member in the range-based ‘for’, there are very few possibilities to misuse the code. Thus it’s a great alternative to UnsafeReference
once we assume that developers are careful enough to avoid binding getTemporary().getMemberReference()
to a const lvalue reference.
Let’s take a final look at the C++23 masterpiece:
#include <ranges>
#include <cassert>
#include <vector>
#include <algorithm>
struct Pixel
{
int r = 0;
int g = 0;
int b = 0;
friend auto operator<=>(const Pixel& a, const Pixel& b) = default;
};
struct Metadata { /* some metadata here, width, height, etc. */ };
class MyRawImage
{
std::vector<Pixel> m_buffer;
Metadata m_metadata;
public:
MyRawImage(std::vector<Pixel> src) : m_buffer(std::move(src)) {}
const Pixel& operator[](int index) const { return m_buffer[index]; }
Pixel& operator[](int index) { return m_buffer[index]; }
template <typename Self>
auto&& data(this Self&& self)
{
return std::forward<Self>(self).m_buffer;
}
const Metadata& information() const& { return m_metadata; }
const Metadata& information() && = delete;
};
MyRawImage loadImage(int i)
{
return std::vector<Pixel>(i * 100, Pixel{ i, i, i });
}
MyRawImage was_problematic(int i)
{
std::vector<Pixel> filtered;
auto filter = [](Pixel p)
{
p.r = std::min(p.r, 0xFF);
p.g = std::min(p.g, 0xFF);
p.b = std::min(p.b, 0xFF);
return p;
};
// in C++23 all the temporaries are guaranteed to be valid during 'for'
for (Pixel p : loadImage(i).data())
filtered.push_back(filter(p));
return filtered;
}
std::vector<Pixel> was_suboptimal(int i)
{
// now it's good: move from the temporary,
// because the rvalue-overload of 'data()' returns an rvalue reference
std::vector<Pixel> filtered = loadImage(i).data();
auto filter = [](Pixel p)
{
p.r = std::min(p.r, 0xFF);
p.g = std::min(p.g, 0xFF);
p.b = std::min(p.b, 0xFF);
return p;
};
for (Pixel& p : filtered)
p = filter(p);
return filtered;
}
Pixel fine_again(int i)
{
auto max = [](auto&& range) -> Pixel
{
return *std::max_element(std::begin(range), std::end(range));
};
// fine: a temporary will be destroyed after the max() calcualtion
return max(loadImage(i).data());
}
int main(int, char**)
{
constexpr static int pattern = 0x12;
constexpr static Pixel pixelPattern = Pixel{ pattern, pattern, pattern };
auto isGood = [](const Pixel& p) { return p == pixelPattern; };
Pixel maxPixel = fine_again(pattern);
assert(maxPixel == pixelPattern);
std::vector<Pixel> pixels = was_suboptimal(pattern);
assert(pixels.end() == std::ranges::find_if_not(pixels, isGood));
MyRawImage img = was_problematic(pattern);
assert(img.data().end() == std::ranges::find_if_not(img.data(), isGood));
// the code below is still UB, but according to the problem's specifications,
// the team is careful and experienced enough not to write so.
// const auto& lvalue = loadImage(pattern).data();
// assert(lvalue.end() == std::ranges::find_if_not(lvalue, isGood));
}
Resources I’d recommend to explore
Just in case you’re here to discover that one intriguing link mentioned in the text above, there is a list of all the sources referenced: