Repository navigation
iterator_base advertises forward_iterator_tag but violates the multi-pass guarantee #1446
Description
Summary
YAML::Node's iterator declares iterator_category = std::forward_iterator_tag but does not satisfy the semantic multi-pass guarantee required by std::forward_iterator (iterator.concept.forward). This causes silent incorrect behaviour when the iterator is used with C++20 <ranges> machinery, which trusts the declared category.
What the standard requires
std::forward_iterator requires the multi-pass guarantee: if a == b, then *a and *b must refer to the same object. This is a semantic requirement; there is no compile-time concept check that enforces it.
std::views::transform and other range adapters propagate the iterator category from the underlying range. If a range claims to be a forward_range, the resulting view also claims to be one. Code downstream that relies on multi-pass behaviour then has formally undefined behaviour.
Reproduction
#include <yaml-cpp/yaml.h>
#include <gtest/gtest.h>
#include <memory>
#include <forward_list>
// Syntactic check passes -- concepts only see iterator_category, not semantics.
static_assert(std::ranges::forward_range<YAML::Node>);
// FAILS at runtime: two copies of the same iterator dereference to distinct objects.
// std::forward_iterator requires *a and *b to refer to the same object when a == b.
// https://en.cppreference.com/w/cpp/iterator/forward_iterator
TEST(YamlCppMapIterator, DereferenceOperator_WithEqualIteratorCopies_ReturnsSameObject)
{
YAML::Node node = YAML::Load("{key: value}");
auto it_a = node.begin();
auto it_b = node.begin();
ASSERT_EQ(it_a, it_b);
const auto& ref_a = *it_a;
const auto& ref_b = *it_b;
EXPECT_EQ(std::addressof(ref_a), std::addressof(ref_b)); // FAILS
}
// Control: std::forward_list::iterator is a true forward iterator.
// operator*() returns int& -- a stable reference to the stored node.
// https://en.cppreference.com/w/cpp/container/forward_list
TEST(ForwardListIterator, DereferenceOperator_WithEqualIteratorCopies_ReturnsSameObject)
{
std::forward_list<int> list = {42};
auto it_a = list.begin();
auto it_b = list.begin();
ASSERT_EQ(it_a, it_b);
const auto& ref_a = *it_a;
const auto& ref_b = *it_b;
EXPECT_EQ(std::addressof(ref_a), std::addressof(ref_b)); // PASSES
}Observed output from the yaml-cpp test:
Expected equality of these values:
std::addressof(ref_a) Which is: 000000D0A4B9EC50
std::addressof(ref_b) Which is: 000000D0A4B9ED50
The addresses differ -- each iterator copy holds its own iterator_value member, and operator*() returns it by value, materialising a new temporary per call.
Root cause
iterator_base<V>::operator*() (iterator.h) returns value_type by value:
value_type operator*() const
{
// constructs and returns a new iterator_value every call
}YAML::Node is a shallow-copying reference handle, not a value type. Each dereference constructs a new iterator_value{Node first; Node second;} wrapping the same underlying ref-counted data but at a new address. Two copies of the same iterator therefore dereference to two distinct C++ objects, violating [iterator.concept.forward]/6.
This is also related to #1326 (value_type being const-qualified), which is a separate but symptomatically similar violation of std::indirectly_readable.
Why this is a bug and not a design choice
Prior to C++20, iterator_category tags were advisory. In C++20, std::forward_iterator is a formal concept with normative semantic requirements. Code using std::views::transform or other range adapters on a YAML::Node will produce a forward_range view based on a false premise. Any algorithm that exploits multi-pass behaviour on that view has formally undefined behaviour, even if it happens to work in practice in a trivial single-pass use.
Proposed fixes
Option A (recommended): Change iterator_category to input_iterator_tag
The iterator is semantically an input iterator. Declaring it as such is honest and correct. Single-pass range-for loops and std::views::transform all work on input ranges. This is a breaking change for callers that pass the iterator to algorithms requiring ForwardIterator, but those callers already have latent UB.
Option B: Return a stable reference from operator*()
Store the current iterator_value inside the iterator as a member and return iterator_value&. However, since two equal iterators are independent copies each holding their own member, they still dereference to different objects. This does not fix the multi-pass violation and adds overhead.
Option C: Shared materialised state
All copies of the same logical iterator position share ownership of a single heap-allocated iterator_value. Satisfies the multi-pass guarantee mechanically, but adds allocation overhead and complicates copy semantics.
Option D (consuming-code workaround): Wrapper that re-declares input_iterator_tag
Users can wrap YAML::Node's iterator in a thin adapter that overrides iterator_category to input_iterator_tag. This is a local fix that does not require yaml-cpp changes, at the cost of boilerplate per use site.
Disclaimer: this bug was found by an AI coding tool (GitHub Copilot), with the assistance and review of a real human. This report was posted at the direct request of that human. The account posting this report is owned and operated by a real person.
Activity
ec36339 commentedon May 30, 2026
Confirming that I am a real person, and that I posted this bug because I am personally using this library and ran into this issue. While I could have written the bug report myself, I am happy with the way the AI wrote it, and I couldn't have written it better myself. I also explicitly asked for the unit tests, because I do not trust AI reasoning. I know how C++20 ranges and iterator concepts work, which is why I care. :)
SGSSGene commentedon May 30, 2026
Hi @ec36339
Thank you for your input.
I am not convinced the explanation from your AI is correct. Specifically the claim
std::forward_iterator requires the multi-pass guarantee: if a == b, then *a and *b must refer to the same object.If I read the description of
std::forward_iteratorI do not see this claim. It also would be surprising, since value semantics for theoperator*will always created different objects and this would disallow many uses ofstd::views::transform.Further, I find the explicit statement:
Here a short demonstration of a "reproduced" case that fails but fails according to your test but is valid and multi pass:
So, I am assuming something else is what breaks the multi pass properties. Maybe you have a better example?
(There might be something wrong with yaml-cpp and i would like to fix it in that case)
md5i commentedon May 31, 2026
Here's what the standard says:
§23.3.4.11 Concept
forward_iteratorforward_iteratorconcept adds copyability, equality comparison, and the multi-pass guarantee, specifiedbelow.
==for forward iterators is that of iterators over the same underlying sequence. However,value-initialized iterators of the same type may be compared and shall compare equal to other value-initialized
iterators of the same type.
[i, s)shall remain valid while[i, s)continues to denote a range.
aandbof typeXoffer the multi-pass guarantee if:(4.1)—
a == bimplies++a == ++band(4.2)— the expression
((void)[](X x){++x;}(a), *a)is equivalent to the expression*a.a == bimplies++a == ++band the removal of the restrictions on the number ofassignments through a mutable iterator (which applies to output iterators) allow the use of multi-pass one-directional
algorithms with forward iterators. — end note]
ec36339 commentedon Jun 1, 2026
I could be wrong, but ...
I think the following combination:
std::forward_iteratorstd::indirectly_readablethat*imust be equality-preserving*ireturns a reference (not a value)would require that for any two iterators
i == j, it must be true thataddressof(*i) == addressof(*j), but the article on "equality-preservation" is so convoluted, you have to be a lawyer and mathematician to understand it, and I don't trust AI on things I don't understand well enough (yes, I'm walking back on claiming that I understand this).In reality, this would probably almost never matter for a YAML iterator.
Also, the
transform_viewI was trying to build was a range of data structures read from YAML objects in a YAML array. The address of whatever*ireturns for a YAML node iterator shouldn't matter at all. This whole thing started with an AI agent cowardly refusing to usestd::views::transformwith YAML iterators.I have had issues with "false forward iterators" in the past. a classic case of shooting myself in the foot was the "iota generator":
This produces a range of 10 results of
some_generator_function()(which could be a stateful generator). The way this breaks is thatiotais a bidirectional range, andtransformpreserves that, but the stateful generator violates the semantic guarantees offorward_iterator, which then causes algorithms and other range adapters to do stuff which causes the generator to be called more often than expected, UNLESS you somehow force the iterator category toinput_iterator.This is why I got trigger happy and trusted the AI too much. It could still be right, but I'm not 100% convinced any more.
afonsojanu commentedon Sep 3, 2026
I tried implementing option A (downgrading iterator_category to input_iterator_tag) to see it through, and it does compile and does make the multi-pass violation honest, but it has a real side effect that I don't think either of the two options above accounts for: std::prev and std::advance called with a negative distance silently stop working the way callers expect.
libc++'s own __advance.h spells this out directly:
and the input_iterator_tag overload of __advance is just
for (; __n > 0; --__n) ++__i;, which does nothing at all for a negative n. The assert is pedantic-only, so in a normal build this fails silently rather than refusing to compile.I confirmed this isn't theoretical: yaml-cpp's own test suite already relies on this.
NodeTest.InteratorOnSequenceBackwardin test/node_test.cpp iterates withfor (iterator it = node.end(); it != node.begin(); --it)and callsprev(it)->IsNull()inside the loop. After downgrading the tag to input_iterator_tag, that test starts failing, becauseprev(it)(which resolves through std::advance) simply returnsitunchanged instead of stepping back one element. The direct--itin the loop condition still works fine since that's a plain member function call, not something tag-dispatched, so the loop itself doesn't break, but any code path that calls std::prev/std::advance/std::distance with a negative amount against a YAML::Node::iterator silently stops moving instead of raising a compile error.So this isn't just a formal correctness nuance that "happens to work in practice" like the C++20 range case, it's an existing, working code path (both inside this repo and plausibly in consumer code) that goes from correct to silently wrong. Given that, I don't think option A is a drop-in fix, at least not without also auditing/replacing every internal std::prev/std::advance call against this iterator and documenting the same trap for downstream users. Might be worth flagging this in the issue itself so nobody picks "just retag it" as an easy first PR without hitting this.
I don't have a clean answer for the right fix here; making operator*() return a genuinely stable reference would need a real internal storage change (caching the constructed Node somewhere with iterator-copy-independent lifetime), which is a bigger redesign than a tag change, so I'm not attempting that myself. Just wanted to flag the concrete breakage I found while looking at this.
SGSSGene commentedon Sep 4, 2026
@afonsojanu thank you for also looking into this. If we use
--itandprev(it)wouldn't that mean we are assuming that it is modelingbidirectional_iterator?I am still not convinced that there is a bug with the category (beside it should be
bidirectional_iterator.)There is another issue, that is maybe related. That is that
YAML::Nodeonly produces 'shallow copies' and behaves more like a 'reference type`. This currently produces unexpected memory usage. Additionally, yaml-cpp does implement correct move-semantics possibly causing all types of unexpected behavior.