You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
qb-linq brings Enumerable-style method chains to modern C++: where, select, order_by, group_by, join, set algebra, percentiles / variance, zip / zip_fold, scan, chunk, materializers, and fused terminals — without std::ranges, without a runtime library. Views are lazy; work happens when you iterate or call a terminal.
Why qb-linq?
LINQ surface
Filters, projections, sorting, grouping, joins, sets, stats, factories — see complete API below.
Lazy by default
Pipelines are cheap to build; execution is driven by iterators and terminals.
where is smart
O(1) construction; first begin() runs a single find_if and caches the result (CRTP query_range_algorithms).
Fused hot paths
sum_if, count_if, any_if, all_if, none_if, aggregate / fold — one pass, no extra where_iterator layer.
CMake-native
find_package(qb-linq) or add_subdirectory → link qb::linq.
#include<iostream>
#include<vector>
#include<qb/linq.h>intmain() {
std::vector<int> v{1, 2, 3, 4, 5, 6};
int s = qb::linq::from(v)
.where([](int x) { return x % 2 == 0; })
.select([](int x) { return x * x; })
.sum();
std::cout << s << '\n'; // 4 + 16 + 36 = 56
}
Include only<qb/linq.h>; other headers stay under qb/linq/. Version macros (QB_LINQ_VERSION_STRING, qb::linq::version, …) come from qb/linq/version.h, generated by CMake — see docs/VERSIONING.md.
Single header (single_header/linq.h)
For a drop-in file (no qb/ include tree, no CMake-generated version.h on disk), add -I /path/to/qb-linq/single_header and:
#include<linq.h>
Same namespace qb::linq and API as <qb/linq.h>; the version is embedded from project(VERSION …) when the file is regenerated.
Regenerate after editing headers under include/qb/linq/ or bumping the project version:
Scripts (repo root — use whichever fits your machine):
python3 scripts/amalgamate_single_header.py — Python 3 (also python … on Windows if that is your launcher).
sh scripts/amalgamate_single_header.sh — POSIX sh; calls python3 internally.
pwsh -File scripts/amalgamate_single_header.ps1 — PowerShell (typical on Windows).
The qb_linq_single_header_test target (see tests/) keeps this file honest in CI.
Design at a glance
enumerable<Handle> — fluent façade; each lazy step returns a new enumerable.
query_range_algorithms (CRTP) — algorithms use derived().begin() / end().
Terminals — sum, to_vector, first, order_by, group_by, … trigger real work (many materialize via shared_ptr).
Caveats — select_many = tuple per row (not C# flatten); except/intersect follow .NET distinct set semantics and need hashablevalue_type; zip/concat inner pipelines may need copy-assignable functors (+[](){}); to_vector() / materialize() return an enumerable wrapping materialized_range (not a raw std::vector); scan iterators must not outlive the scan_view. Full notes in qb/linq.h.
Complete API reference
All operations below live in namespace qb::linq. Method names use snake_case.
Unless noted, methods are const and return a new enumerable for lazy ops, or a value / materialized range for terminals.
Free functions — factories & entry points
API
Role
from(container&) / from(container const&)
Non-owning iterators — the container must outlive the pipeline for lazy ops; do not chain from(temp).where… (see qb/linq.h caveats).
Tuple of one projection per loader per row (not nested flattening).
flat_map(f) / select_many_flatten(f)
True C# SelectMany: project each element to a sub-range via f and flatten (lazy).
where(pred)
Filter; first begin() may cache first match.
where_indexed(pred)
pred(element, index) (lazy).
of_type<U>()
Polymorphic dynamic_cast filter (value_type must be pointer, pointee polymorphic).
cast<U>()
Lazy static_cast<U> of each element (complement to of_type).
skip(n)
Drop first n.
skip_while(pred)
Drop while predicate true.
take(n)
Take n (int; negative magnitude like LINQ; INT_MIN → empty).
take_while(pred)
Take while predicate true.
skip_last(n) / take_last(n)
Materialize then trim head/tail.
reverse()
Reverse order (underlying chain must be bidirectional). Composes correctly with take(n) and take_while(pred) via dedicated bounds (logical prefix only).
Composition & extra lazy views
API
Role
concat(rhs)
Concatenate another range (same value_type).
zip(rhs)
Pairwise zip; stops at shorter length.
zip(r2, r3)
Three-way zip; std::tuple of references; stops at shortest length.
zip_fold(rhs, init, f)
Single-pass fold over pairs f(acc, left, right); same length rule as zip.
append(v) / prepend(v)
Single element after/before sequence.
default_if_empty() / default_if_empty(def)
Sentinel when empty.
enumerate()
(index, element) pairs.
scan(seed, f)
Running fold; yields each accumulator.
chunk(size)
Chunks as std::vector slices (lazy per chunk).
stride(step)
Every step-th element (0 clamped to 1).
sliding_window(size)
Overlapping windows of size elements (each as std::vector); slides by one element.
Right outer join; result_sel(std::optional<outer>, inner) — empty when no match.
group_join(inner, outer_key, inner_key)
Outer + inner groups per key.
Set-style (after materialization / hash)
API
Role
except(rhs)
Set difference (.NET Except): distinct left values not in rhs, first-seen order.
intersect(rhs)
Set intersection (.NET Intersect): distinct left values also in rhs, first-seen order.
except_by(rhs, keyL, keyR)
.NET ExceptBy: distinct left rows by keyL, excluding keys from rhs via keyR.
intersect_by(rhs, keyL, keyR)
.NET IntersectBy: distinct left rows by key present in rhs keys.
union_by(rhs, keyL, keyR)
.NET UnionBy: same value_type both sides; unique keys, left then new right keys.
count_by(keyfn)
.NET CountBy: (key, count) pairs in first-seen key order (materialized).
union_with(rhs)
concat + distinct.
Materialization — collections & maps
API
Role
to_vector()
Eager materialization: returns enumerable<materialized_range<…>> owning a std::vector via shared_ptr (iterate, chain further, or copy out — not a std::vector prvalue). Random-access sources may reserve.
materialize()
Alias of to_vector().
to_set() / to_unordered_set()
Unique sets.
to_map(keyf, elemf)
std::map (ordered keys).
to_unordered_map(keyf, elemf)
std::unordered_map (last wins on duplicate key).
to_dictionary(keyf, elemf)
unordered_map; throws on duplicate key.
Search & indexing
API
Role
contains(v)
Linear search ==.
contains(v, eq)
Custom equality.
index_of(v) / last_index_of(v)
std::optional<std::size_t>.
index_of(v, eq) / last_index_of(v, eq)
With custom equality.
Counting & boolean quantifiers
API
Role
any()
Non-empty?
count() / long_count()
std::distance (full scan).
try_get_non_enumerated_count()
optional size for random-access ranges only; no linear scan.
count_if / long_count_if
Predicate count (fused).
any_if / all_if / none_if
Fused predicate quantifiers.
Element access & single-element contracts
API
Role
first() / last()
Throws if empty. last() works with forward-only iterators (linear scan; bidirectional+ uses std::prev).
first_or_default() / last_or_default()
Value or value_type{} (requires default-constructible value_type).
Apply f to each element; returns copy of current stage for safe chaining (const& / & overloads).
Map / lookup bracket (when handle supports it)
API
Role
operator[](key)
Available on map-backed materialized ranges (e.g. to_dictionary, grouped structures) when the underlying container exposes at.
Coverage: the table above lists every forwarding entry point on enumerable plus all documented free factories in enumerable.h. Internal iterator adaptors (select_iterator, …) live in iterators.h; combinators (concat_view, zip_view, …) in detail/extra_views.h.
Examples
Pipeline: filter → map → take → materialize
std::vector<int> data{3, 1, 4, 1, 5, 9, 2, 6};
auto v = qb::linq::from(data)
.where([](int x) { return x > 2; })
.select([](int x) { return x * x; })
.take(3)
.to_vector();
Fused aggregate
std::vector<int> xs{1, 2, 3, 4, 5, 6};
auto n = qb::linq::from(xs).count_if([](int x) { return x % 2 == 0; });
auto s = qb::linq::from(xs).sum_if([](int x) { return x % 2 == 0; });
Presets:cmake --preset dev · cmake --build build/dev · ctest --preset dev
(release, ci, docs — see CMakePresets.json.)
Performance & benchmarks
The benchmarks/ suite (Google Benchmark) compares many qb-linq paths against small hand-written loops in the same translation unit. Enable it with QB_BUILD_BENCHMARKS=ON (see above), then run the qb_linq_benchmark executable. All timings are machine-, compiler-, and flag-specific; use them for local regression checks, not as absolute promises. For scan iterator copies vs a straight range-for, and select with an empty class functor vs a large callable object, see benchmarks/bench_scan_contract.cpp.
What to expect
Simple terminals on a plain range (e.g. sum on from(vector)) are usually close to a naive loop: the hot path is still a forward iteration with little extra logic.
Lazy stacks (select, where, concat, stride, scan, …) add real work: wrapped iterators, stored callables, and sometimes downgraded iterator categories versus raw pointers or index loops. A noticeable gap versus a single fused loop is normal, especially on MSVC, where heavy template layering does not always optimize away completely.
scan stores the fold functor in the scan_view; iterators hold a non-owning pointer to it (no shared_ptr / atomic refcount). Treat it like a standard range: do not keep scan_iterators past the lifetime of the view that produced them.
select / where / take_while store callables with empty-base optimization when F is an empty class type (C++17), shrinking iterator objects for stateless function objects. On MSVC, iterator adaptors use __declspec(empty_bases) so an empty F does not double the iterator size under multiple inheritance.
Materializers such as to_vector() build a materialized_range backed by std::shared_ptr. You pay for that ownership model, not just element copying—expect a higher cost than reserve + push_back into a bare std::vector with no shared handle.
Views that allocate per step (e.g. chunk, which fills a std::vector per chunk) are inherently more expensive than one pass over scalars.
Some benchmarks compare different algorithms (e.g. one-pass Welford variance vs a two-pass sum-of-squares formulation). A gap there reflects numerics and work per element, not a broken sum.
Takeaway:qb-linq prioritizes a LINQ-like API, lazy composition, and safe materialization (shared ownership where needed). It is not a guarantee of matching hand-tuned assembly on every pipeline. For hot spots, measure with qb_linq_benchmark (or your own harness) on your target toolchain.