I am continuing to go through the sus::iter::Iterator
methods and turn them all into constexpr
based on
the performance results from doing so. With constexpr
an iterator of chunks_exact()
, and
take_while()
is able to outperform a “bare-metal” loop over pointers. More on
that over at Mastodon.
As I am doing so, I am writing tests, and this test felt really good to write!
static_assert(sus::Vec<f32>::with(2.f, 3.f, 2.f)
.into_iter()
.max_by(&f32::total_cmp) == sus::some(3.f));
So I interrogated myself why it did. First off, I was happy that you can’t write this wrong. You can’t call max()
on floats because they are not strongly ordered. In other words, operator<=>()
on floats returns std::partial_ordering
, not std::strong_ordering
. That means that floats satisfy the PartialOrd
concept but not the Ord
concept. And max()
requires Ord
.
This is true for float
as well, but f32
is nicer to work with for reasons like the NAN
constant and that is_nan()
is constexpr
unlike std::isnan()
until C++23.
// Doesn't compile, as floats are not strongly ordered. Constraint does not match.
// error C7500: 'max': no function satisfied its constraints
sus::Vec<f32>::with(2.f, 3.f, 2.f).into_iter().max();
The f32::total_cmp()
method provides a total ordering over floats and does return std::strong_ordering
. It does the same thing as f32::total_cmp in Rust std. So we can use it as a callback in max_by()
.
What does the standard ranges library do when you try to max floats, I wondered. At least on MSVC this is what I got.
// Ok sure, looks good.
static_assert(std::ranges::max(
std::vector<f32>({2.f, 3.f, 2.f})) == 3.f);
// At compile time, NAN is largest.
static_assert(std::ranges::max(
std::vector<f32>({2.f, 3.f, f32::NAN})).is_nan());
// Unless 3 comes after.
static_assert(std::ranges::max(
std::vector<f32>({f32::NAN, 3.f, 2.f})) == 3.f);
// At run time, different answers. 3 is largest if it comes first.
EXPECT_EQ(std::ranges::max(
std::vector<f32>({2.f, 3.f, f32::NAN})), 3.f);
// At run time, NAN is larger if it comes first.
EXPECT_EQ(std::ranges::max(
std::vector<f32>({f32::NAN, 3.f, 2.f})).is_nan(), true);
// Undefined behaviour!!!
EXPECT_EQ(
std::ranges::max(std::vector<f32>()), 0.f);
Those results are really not okay! They are the kind of thing that doesn’t show up in tests and then completely takes down production. Maybe introduces a security vuln.
Here’s what I got with Subspace iterators.
// Reproducible ordering.
static_assert(sus::Vec<f32>::with(2.f, 3.f, 2.f)
.into_iter()
.max_by(&f32::total_cmp) == sus::some(3.f));
static_assert(sus::Vec<f32>::with(2.f, 3.f, f32::NAN)
.into_iter()
.max_by(&f32::total_cmp).unwrap().is_nan());
static_assert(sus::Vec<f32>::with(f32::NAN, 3.f, 2.f)
.into_iter()
.max_by(&f32::total_cmp).unwrap().is_nan());
// Same thing at runtime as at compile time.
EXPECT_EQ(sus::Vec<f32>::with(2.f, 3.f, f32::NAN)
.into_iter()
.max_by(&f32::total_cmp).unwrap().is_nan(), true);
EXPECT_EQ(sus::Vec<f32>::with(f32::NAN, 3.f, 2.f)
.into_iter()
.max_by(&f32::total_cmp).unwrap().is_nan(), true);
// Defined behaviour. ^_^b
static_assert(sus::Vec<f32>::with()
.into_iter()
.max_by(&f32::total_cmp) == sus::none());
Sure you can just write a total_cmp()
method yourself and use that as the comparator with the standard ranges
library. But you don’t have to, it compiles anyway so there’s nothing suggesting something is wrong. And then it
returns garbage or corrupts your compilation with Undefined Behaviour.
With Subspace total_cmp()
is already there for you, and non-sense inputs don’t compile.
This stuff is why Rust is pleasant to work in. C++ can be too.