Skip to content

The C++ half in the language's own shapes - #9

Merged
tamnd merged 1 commit into
mainfrom
idiom
Aug 22, 2026
Merged

tamnd merged 1 commit into
mainfrom
idiom

Conversation

@tamnd

@tamnd tamnd commented Aug 22, 2026

Copy link
Copy Markdown
Owner

The idiom item on zu-c's scorecard, which clients.toml puts at 15 and is the largest single item on any card: "The language's own shapes: how it iterates, how it releases a resource, how it awaits, what it raises. A binding that reads like the C ABI in another syntax is a binding nobody wants to write against."

The header was already idiomatic in shape. What was missing was the part a compiler checks.

A dropped return was a lost failure

Ninety of the calls in zu.hpp answer something a caller must not drop, and three of them said so. A try_ call answers a std::expected whose error half is the failure report, so this compiles, throws nothing, returns nothing, and loses the reason:

conn.try_query(q);   // the report was the return value

Every expected return and every factory is [[nodiscard]] now. It caught a real one in test_conditions.cpp on the first build, which is what a gate is for.

The bug the sweep found

zu.hpp read __cpp_lib_expected before anything had defined it. <version> was not included, and none of the twenty headers above the check happens to pull that macro in on libstdc++. An undefined identifier is nought in a preprocessor arithmetic expression, so:

$ g++ -std=gnu++23 -fsyntax-only -I include probe.cpp
error: #error "ZU_HAS_EXPECTED is 0"

$ # the same file with #include <version> on the line above
error: #error "ZU_HAS_EXPECTED is 1"

GCC 13 at -std=c++23, where std::expected has been available since GCC 12. The whole try_ half of the API went undeclared on a toolchain that has it.

Worse than absent, it depended on include order: a translation unit that had already included <version> or <expected> got a different zu.hpp than one that had not, in the same build.

And nothing said so

test_expected.cpp is written as one #if with a single placeholder case in the #else, for the C++20 floor where there is genuinely nothing to run. With the flag stuck off, ctest ran the placeholder, printed Passed, and eleven cases about the half of the API a caller who builds without exceptions depends on had not been compiled by anything.

That branch now carries an #error. Below C++23 there is nothing to run and the throwing half is complete on its own; at C++23 and above, this half missing is a broken build rather than a quiet one, and the compiler is the only thing positioned to notice.

Printing

<format> was included at the top of the header and ZU_HAS_FORMAT was defined beside it. Neither was used anywhere in the header, the tests, the examples or the README, which is a header claiming a capability it did not have.

There is now a to_string for Status, Severity, Type, TemporalKind, Position, Node, Rel, Temporal, Error and Value, and a std::formatter over each that is one line apiece. The C++20 floor gets the same text as C++23 and the two cannot come to disagree about what a value looks like, for the same reason the throwing and try_ halves are one line over one implementation.

Each formatter inherits formatter<string_view>, so the whole standard format spec arrives with it and a bad spec is rejected at compile time rather than ignored:

std::format("[{:>8}]", zu::Type::string)   // "[     str]"
std::format("{}", e.error())               // "42001 at line 1, column 17: ..."

No operator<< to go with it, deliberately. <ostream> is one of the heaviest headers in the standard library and it would land in every translation unit that includes this one whether it prints or not, for a wrapper whose first line is that it includes zu.h and calls nothing else. A caller who wants a stream writes os << zu::to_string(e), which is one call, no dependency and the same bytes.

Error::report() stays what it was, the three-line spelling with a caret under the column. to_string(Error) is the one-line one, which is what a log wants.

test_idiom.cpp

Mostly static_assert, and that is the point. A concept that holds is a promise the compiler keeps at every call site rather than one a case checked once, and when a change breaks it, it breaks here with the name of the concept in the message instead of three repositories away inside a std::views expression a user wrote.

What it pins:

  • Result is a sized, common, random access range of Row, and its iterator is a random_access_iterator. Random access rather than input is the difference between rows[500] costing a bounds check and costing five hundred reads.
  • Result is deliberately not a borrowed_range. That is the compiler's half of the rule the README states in English: a Row points into the Result it came from, so conn.query(q) | std::views::filter(f) over a temporary answers a std::ranges::dangling and the mistake is a compile error where it was written. A Result marked borrowed would turn that into a use after free that happens to work in a debug build.
  • Handles move and do not copy, and move without throwing. A copied Connection would be two owners of one zu_conn and a double close.
  • Every exception descends from zu::Exception and from std::runtime_error, so a host with one catch at the top of a request gets the message and a host that wants the condition class catches DataError.
  • The value structs are std::regular, so they go in a container and can be searched for.

The runtime half runs a real views pipeline, a real ranges::count_if, and checks the numbers, so the concepts cannot be satisfied by something that compiles and does nothing.

One promise of this item is not testable from inside the language. A case that dropped a [[nodiscard]] return to prove the warning fires would be a case that fails the build, since CI compiles this repository with -Werror. That one is enforced at every call site by the compiler and is deliberately not a case.

Checked

Built and run on a Linux box rather than a laptop, GCC 13.3, -Werror:

100% tests passed, 0 tests failed out of 41

41 of 41, every file at C++23 and again at the C++20 floor. test_expected went from 1 case to 11 at C++23, which is the eleven that had not been compiled anywhere.

Three things, and the middle one is a bug the other two found.

Ninety of the calls in zu.hpp answer something a caller must not drop
and only three said so. A try_ call answers a std::expected whose error
half IS the failure report, so `conn.try_query(q);` on a line of its own
compiles, throws nothing, returns nothing, and loses the reason. Every
expected return and every factory is [[nodiscard]] now. It caught a real
one in test_conditions.cpp on the first build, which is the point.

Then the bug. zu.hpp read __cpp_lib_expected before anything had defined
it, because <version> was not included and none of the twenty headers
above the check happens to pull that macro in on libstdc++. An undefined
macro is nought in a preprocessor arithmetic expression, so
ZU_HAS_EXPECTED came out 0 on GCC 13 at -std=c++23, where std::expected
has been available since GCC 12. The whole try_ half of the API went
undeclared on a toolchain that has it. Worse than absent: it depended on
include order, so a translation unit that had already included <version>
or <expected> got a different zu.hpp than one that had not, in the same
build.

Nothing said so, because test_expected.cpp is written as one #if and
compiles to a single placeholder case when the flag is off. ctest ran
that placeholder, printed Passed, and eleven cases about the half a
caller who builds without exceptions depends on had not been compiled by
anything. So the floor branch of that file now carries an #error: below
C++23 there is nothing to run and the throwing half is complete on its
own, but at C++23 and above, this half missing is a broken build rather
than a quiet one.

Third, printing. <format> was included at the top of the header and
ZU_HAS_FORMAT was defined beside it, and neither was used anywhere in
the header, the tests, the examples or the README, which is a header
claiming a capability it did not have. There is now a to_string for
every type worth printing and a std::formatter over each, one line
apiece, so the C++20 floor gets the same text as C++23 and the two
cannot come to disagree. Each formatter inherits formatter<string_view>,
which brings the whole standard format spec with it: {:>8} pads a status
the way it pads any other string and none of that had to be written. No
operator<< to go with it, on purpose, because <ostream> would land in
every translation unit that includes this header whether it prints or
not, and a caller who wants a stream writes os << zu::to_string(e).

test_idiom.cpp is the new file and it is mostly static_assert, which is
also the point. A Result is a sized, common, random access range of Row
and is deliberately not a borrowed one, so filtering a temporary result
answers std::ranges::dangling and the mistake is a compile error where
it was written rather than a read of freed memory that works in a debug
build. Handles move and do not copy. Exceptions descend from
std::runtime_error. The value structs are std::regular. The runtime half
runs a real views pipeline and checks the number, so the concepts cannot
be satisfied by something that compiles and does nothing.

41 of 41 green with -Werror, at C++23 and at the C++20 floor.
@tamnd
tamnd merged commit eee7efd into main Aug 22, 2026
4 of 7 checks passed
@tamnd
tamnd deleted the idiom branch August 22, 2026 17:37
tamnd added a commit to tamnd/zu that referenced this pull request Aug 22, 2026
The largest item on any card here, at 15, and the last big one on
zu-c's. The card goes from 50 to 67 of the 90 it is scored out of, since
zu-c owes no api-map and no perf.

What landed is tamnd/zu-c#9. Ninety [[nodiscard]] attributes on the
calls whose return value is the failure report; a to_string and a
std::formatter for every type worth printing, which is what ZU_HAS_FORMAT
was for and had never been used for; and test_idiom.cpp, which is mostly
static_assert on purpose, because a Result being a random access range
and deliberately not a borrowed one is a promise the compiler should
keep at every call site rather than one a case checked once.

The sweep found a bug on its way through. zu.hpp read
__cpp_lib_expected before <version> had defined it, so ZU_HAS_EXPECTED
came out 0 on GCC 13 at -std=c++23 and the whole try_ half of the API
went undeclared on a toolchain that has it. It depended on include
order, so two translation units in one build got two different headers.
Nothing said so, because the suite for that half is one #if and
compiled down to a single placeholder case that passed. That file now
carries an #error on the floor branch: at C++23 and above, this half
missing is a broken build rather than a quiet one.

Left on the card: reference, install and stability, at 10 each.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant