(This post got far too long and in depth, so I pulled it out into its own thread.)
The direct equivalent of the C++ would be
// this doesn't quite work as-is right now,
// but it is close to working via multiple potential avenues.
pub trait Midpoint {
fn midpoint(a: Self, b: Self) -> Self;
}
pub fn midpoint<T: Midpoint>(a: T, b: T) -> Self {
<T as Midpoint>::midpoint(a, b)
}
impl<Tp> Midpoint for Tp
where
Tp: Integral,
{
fn midpoint(a: Tp, b: Tp) -> Tp {
const BITSHIFT: u32 = Tp::BITS - 1;
let diff = a.wrapping_sub(b);
let sign_bit = Tp::try_from((b < a) as u8).unwrap();
let half_diff = (diff / (Tp::ONE + Tp::ONE))
+ (sign_bit << BITSHIFT)
+ (sign_bit & diff);
a.wrapping_add(half_diff)
}
}
impl<Fp> Midpoint for Fp
where
Fp: Floating,
{
fn midpoint(a: Fp, b: Fp) -> Fp {
let lo = Fp::MIN * Fp::from(2_i8);
let hi = Fp::MAX / Fp::from(2_i8);
if a.abs() <= hi && b.abs() <= hi {
// typical case: overflow is impossible
(a + b) / Fp::from(2_i8) // always correctly rounded
} else if a.abs() < lo {
a + (b / Fp::from(2_i8)) // not safe to halve a
} else if b.abs() < lo {
(a / Fp::from(2_i8)) + b // not safe to halve b
} else {
(a / Fp::from(2_i8)) + (b / Fp::from(2_i8))
}
}
}
(I'm using the funty traits[1] here. Using num-traits would be an incorrect port, as the C++ midpoint is only provided for primitive arithmetic types other than bool. Also, I'm only providing the arithmetic overload, not the pointer overload; since Rust doesn't have function overloading, that'd be a separate (and unsafe) function.)
The problem with this implementation is that we don't define what should happen if there is some T: Integral + Floating. C++ just doesn't care and is happy to defer any errors until you happen to provide such a type. Rust does care and wants you to resolve this ambiguity up front. The current limitation of Rust's traits is that there's currently no way to resolve this overlap without falling back to listing the closed set of non-overlapping impls explicitly. There are, however, multiple semi-planned future extensions which would permit this overlap to be resolved[2]. Resolving the overlap for actually open traits is a difficult problem (see: the problems with specialization) and one which C++ can barely be said to provide a solution for[3] despite not having the soundness issue in specialization Rust has since it doesn't have type-level lifetimes.
But to repeat what's been said, C++ templates are closer to Rust macros than Rust generics. There's some extra magic such that C++ templates are automatically and only instantiated for types on an as needed basis, but by writing a Rust macro which stamps out a trait implementation for multiple types you're doing exactly what C++ templates do, not "cheating" in any way. When there's a closed set of implementing types, preinstantiating the templates can even be preferable; C++ has explicit template instantiation for this exact reason.
Don't think of rust macros, when used to generate trait implementations, as a more powerful/principled version of preprocessor macros; think of them as a more powerful version of extern template. Because in this role, that's exactly the function they're serving.
If you want to be pedantic about it, Rust's function generics aren't even really attempting to be an alternative to C++ function templates; they're much closer in design to the use of pure virtual (interface) inheritance, except beefed up and able to work with instances by value without slicing by virtue of being monomorphic instead of polymorphic. C++ function templates are much more analogous to Rust's function macros, except for two main limitations[4] (no access to argument types by name and no automatic deduplication of same-type instantiation).
Rust's macros serve multiple purposes with one (perhaps overly) general functionality, just like C++'s templates do. They're significantly powerful, and avoiding them definitely should be preferred when practical, but they're still a first-class feature that can and should be utilized to great effect. That a Rust macro is used isn't immediately an indication of a failure of the rest of the language to offer a reasonable solution, nor of the author for not finding a "better" approach[5]; sometimes a simple syntactical templating of some code is exactly the solution the problem space is asking for.
To tie this (strenuously) back to the original topic (Rust having strong "if it compiles, it probably works as intended" vibes), this is a primary component[6] of why Rust has this feeling more than C++ does.
Needless pettiness and pedantry
This gets into the two axis of type safety; static versus dynamic typing, and strict versus weak typing. Both Rust and C++ are statically typed languages[7]. However, C++ is generally[8] more weakly typed, and Rust is generally[9] more strictly typed.
C++'s most understood source of "weak" typing is the availability[10] of implicit type conversions/coercions. But perhaps even more importantly, C++ templates are at most weakly statically typed.
The most "fun" issue in C++ code I ever had to deal with[11] was due to templates' weak typing, where what was supposed to be a template specialization wasn't actually getting used and instead was falling back to a SFINAE-style fallback implementation because of a trivial but hard-to-spot mistake making template substitution always fail.
C++ templates are still statically typed, since they will prevent compilation from succeeding if a substitution creates a type/compilation error (but not for substitution failure, of course), but they're weakly typed (at least by my categorization; strong definitions are highly contentious) because they're not possible to check until resolving a fully concrete instantiation[12].
Furthermore, the dialect of C++ where you use auto (almost) everywhere is effectively just a bad dynamically typed language. You're giving up the actually nice conveniences that dynamically typed languages are able to provide in order to get statically typed AOT optimizations. In exchange, you have to deal with the compiler providing obtuse error backtraces that describe how something went wrong (some compile error in the implementation of a deep stack of SFINAE-selected templates) rather than why something went wrong (a template argument fails to model the required concepts[13]).
Dynamic versus static is typically discussed as a binary, but there's more of a gradient where I identify four meaningful points to raise an error, though they're not quite strictly ordered:
- Generic; errors are detected based just on the source as written, even if it's completely unused. (Nongeneric code in a statically typed language is trivially here, as are Rust generics.)
- Instantiation; errors are detected as a result of instantiation of generic code with concrete types. (C++ templates and Rust macros fall under this category.)
- Codegen; errors which occur late enough that optimization can eliminate them, like linker errors. (Rust const panics are currently (but questionably) here. C++ doesn't really have this as a concept; any program with such an error would be ill-formed, no diagnostic required.)
- Runtime; errors which don't prevent the execution of code which "happens before" it.
Ignoring the problem child of codegen class errors, you can see it as compiletime (static) versus runtime (dynamic) errors, but with compiletime errors further refined into typesystem-compiletime (generic) and typesystem-runtime (instantiation). The thought of static typing is that by pushing errors sooner on this gradient, they can be preemptively detected and prevented. The thought of dynamic typing is that by pushing errors later on this gradient, more information is known and can be used to permit more code that avoids the error case dynamically. These different priorities map just as well onto the generic/instantiation split as they do the compiletime/runtime split.
Dynamic practices work great in smallish scopes / for smallish projects, because it's possible to hold the entire (or at least most of) the project's requirements in your head at once, and template instantiation depth (including auto prompted implicit templates) is typically rather low. But projects don't stay small for long (and even if they do, it's likely for you or someone else to come back for maintenance long after that context has paged out), and the required context just continues to grow. Especially when a component has generics/templates at its interface, it becomes increasingly difficult to constrain that needed context and keep instantiation depth shallow.
If I'm being extra petty, I can draw a line between C++'s template behavior and C's namespaceless ODR. In pre-ANSI C, C didn't even have struct field namespacing, and every field name was required to be different from every other symbol (type, function, or field); a field name was basically just an offset and pointer->field just an access at that offset independent from whatever pointer happened to be. (Pre-ANSI, C essentially was just a fancy single-pass macro assembler, before it evolved into being a "proper" language of its own.) C++ templates follow a very similar philosophy; while overloading, templating, virtual, and the various name lookup rules permit a name foo to refer to multiple concrete items, templates make an assumption that foo (at least when used in a given syntactic manner) always refers to the same semantic. E.g. that if a class has a size member, the class models some sort of container, and size is invocable on a const object of that class with no arguments and no preconditions, does not throw exceptions, and returns the number of elements in the container, equivalent to std::distance(begin(), end()). Defining a size member which e.g. computes the area of a shape would be considered incorrect, at least if that type ever finds its way into a template expecting an STL-ish container.
(If I'm being extra extra petty, I can claim that this is part of the reason non-STL C++ naming conventions often use TitleCase for everything: to avoid clashing with the semantic meaning implied by snake_case names used by the STL. Your type can't accidentally appear to conform to an STL template requirement if you never use the names it expects. I will also continue to marvel at the fact that C and C++ make the definition of any _TitleCase or __dunderscore identifiers ill-formed, no diagnostic required (UB), and that it took clang until 2020 to provide the option of warning on them, and IIUC GCC still doesn't.)
The entire purpose of Rust's trait-directed generics is to dismiss the structural typing used by C++ templates and embrace nominal typing; that if I write code using ExactSizeIterator::len, I get specifically that function, with that semantic, no matter the shenanigans[14] that downstream may be pulling. Specifically because of this breeds the confidence that "if it compiles, it probably works," as because we have this guarantee, the risk of something compiling but accidentally not providing the expected semantics (i.e. that would make the code work) is very low.
As an added bonus, because the set of functionality used by generics is declared and known ahead of time, it's possible to entirely[15] check that generics are well-formed (don't have any errors) before instantiation, while they're still generic. That Rust's generics are checked early, along with coherence restrictions and forbiddance of overlapping impls, isn't fundamental to the nominal type system, but they do significantly contribute to the confidence that the code works when it compiles.
Needless pettiness and pedantry
In more dynamic languages (e.g. I'm primarily thinking about JavaScript), significantly more unit testing is done, and TDD is more widely used (or at least held as an ideal). It's fairly common to see discussions around the use of (gradual) typing (e.g. TypeScript for JavaScript) pitted against unit testing (and sometimes TDD) as one removing the need for the other. (My pet "favorite" to observe is the choice between inferred and explicit return types[16], as well as the use of satisfies more generally.)
C++ templates being checked only after instantiation is IMHO exactly the same class of tradeoff (and I consistently come down on the side of strictly-strong types by default). It "doesn't matter" if templates aren't checked until terminally instantiated, because "of course" you have tests which instantiate them with (all?) the relevant types.
But in most cases, this is busywork that the compiler could be doing automatically for me, rather than me having to create a minimally modeling mock in order to test that a template instantiation succeeds without error. (Oops, I've basically reinvented trait generics now!)
My spiciest take here is that the concept of 100% code coverage comes from and is only really meaningful in dynamically typed languages, where you can write nonsense code that won't be rejected until executed[17]. In such languages, even mostly useless coverage still accomplishes something, in verifying that the code "typechecks" with the executed type. In a statically typechecked context, the static type checking accomplishes anything a 100% coverage requirement actually measures.
That's not to say that tests aren't still useful, nor that coverage isn't still a useful measurement (though I'd argue branch coverage is a better measure than line coverage); rather that testing trivial cases achieves coverage but doesn't really test anything other than that the types line up, which is a meaningless test if static typechecking already proved that, but is actually meaningful in a dynamically typed system, so 100% line coverage actually means something. (Still not that much, relatively speaking, but it is still something.)
I fully understand the benefits of having access to structural abstraction options; I've previously proposed the concept macro fn, which would act much like C++ function templates in that a) it behaves as function boundary/item after instantiation[18] and b) it enables generic types and the return type to be "wildcard bound," where any usage not satisfied by generic bounds (or dependent on such, transitively) has its resolution and type inference deferred until Instantiation.
What I take issue at is claiming that C++ templating is somehow objectively superior to Rust's trait generics, or that Rust generics "don't work." It's completely fine to be more proficient at and more comfortable with C++ templates; the two are very different and excel at different things.
C++ is objectively better at keeping things which look like they should be trivial trivial, via the use of auto templates. Rust requires significantly more buy-in in order to state your static requirements up front.
C++'s greatest weakness, however, is its fractal complexity. You can write simple code, but the simple code probably breaks in edge cases. Rust's greatest strength, on the other hand, is its consistency; if the generic code compiles, there (probably) aren't going to be any surprises when compiling uses of the generic.
Rust's trait generics are very functional, and the majority of developers using Rust are satisfied with the system. They can't do everything C++ templates can do, but that's fine. They work perfectly well for what they're supposed to — for writing code generic over a statically, nominally typed interface — and for when you do just want to stamp out a code template for some small, closed set of types, macros generating trait implementations is a perfectly fine solution, doing exactly what you want.
Finally, I'd be remiss if I didn't mention one last reason crates like midpoint or std might choose to use macro generated instead of generic trait implementations — (downstream) compile time performance. When an implementation in a crate is generic, it's recompiled by every[19] downstream crate (codegen unit). If an impls is nongeneric, (e.g. because it was macro generated for the concrete types) however, the code is compiled (and optimized) only once in the defining crate (up to inlining, of course). Performance is a very common reason for libraries to choose less convenient ways to write code, in any language.
Similarly, compile error quality is another reason crates might prefer macro-generated implementations over generic impls; if only a single trait with nongeneric impls is present, the error messages are typically fairly clear and straightforward. If generic implementations of the relevant trait are present, the compile error when a type doesn't satisfy the trait is more involved, because there are more potential reasons for the error and avenues to addressing it. Many libraries will take the choice to make their implementation a bit less convenient if it can improve downstream errors; by design, code compilation fails more often than it succeeds, and the primary responsibility of the compiler (and to an extent, well-engineered libraries) is to diagnose potentially ill-formed code (i.e. cargo check), and it's comparatively much rarer that the compiler is asked to build or test the code as well (and even then it still has the task of first checking the code).
Being generic over arithmetic types is probably the hardest thing to be generic over in Rust. This is unfortunate, since the primitive arithmetic types are perhaps the easiest thing conceptually to want to be generic over. However, it's worth noting that in C++, implicit promotion to
intmakes it surprisingly difficult to get correct as well. ↩︎There's three potential ways of providing a resolution that I'm aware of:
- Specialization: mark the potentially overlapping impls as overridable (i.e.
default fn) and then provide a lattice impl forT: Integral + Floatingto be used when both bounds are satisfied. If you set the lattice impl to cause a post-mono instantiation error, you've exactly recreated C++'s behavior. However, note this is only possible in this case because the traits in question are bound by'static; specializing on (potential) lifetime bounds (a concept which does not exist in C++) is fundamentally unsound, as lifetimes fundamentally must no longer exist when selecting the monomorphization to use. - Fundamental traits: if traits could be marked as "finally sealed" (i.e. all impls are provided alongside the trait definition and no more will ever be added), then checking
impl<T: Integral>can behave w.r.t. coherence checking as if each of the base impls forIntegralwere provided separately, effectively giving us the macro-stamped impls without the macro stamping. A variant of this functionality already kind of exists with fundamental types, thus my calling this fundamental traits. - Negative coherence: by providing explicit impls like
impl !Floating for u32and bounds liketrait Integral: !Floating + ..., it's possible to inform the compiler that no types implement bothFloatingandIntegralat the same time (that the traits are mutually exclusive), and thus that the overlap case doesn't occur. Explicit negative impls must be required, since normally adding a trait implementation is considered nonbreaking. Fundamental traits (and types) are essentially an automatic form of negative coherence, as they function as a promise that any currently unimplemented traits won't be implemented (but with a caveat for fundamental types, since their negative coherence, instead of allowing proving the complete absence of an impl, allows downstream to add impls of upstream traits when the type is "covered" (i.e. the impl is considered local if a local type is provided as a generic argument to an otherwise upstream fundamental type)).
- Specialization: mark the potentially overlapping impls as overridable (i.e.
The solution in C++ would be to make the overloads no longer overlap by putting an explicit check of the negative of the other bound in the
enable_ifcondition, then adding the lattice impl which requires both to be satisfied. I call this "barely" a solution since given two open type sets, there might not even exist a type which satisfies both available to the library to test and observe the overlap, despite downstream being able to define such a type. If the lattice impl was missed, then downstream has no recourse available other than to provide a template specialization for each concrete type in the overlap and can't even delegate to either upstream impl since there's no way to disambiguate between the two ambiguous overloads. ↩︎Of course, Rust's metaprogramming facilities are also relatively limited in comparison to C++'s, without good access to real specialization options (e.g. by C++ SFINAE or constexpr if equivalents), although you can get surprisingly close in function macros by (ab)using auto(de)ref and working with the trait system instead of against it. But the ease of metaprogramming tricks isn't what is under scrutiny; it's the experience of authoring relatively simple compositional templates/generics that just do some plumbing between some other (presumably generic) functionality; where
auto-everything is enough in C++. ↩︎This is unlike C preprocessor macros, where it definitely can be argued that they should be avoided as much as practically possible because of how unprincipled and ad hoc they are with absolutely no knowledge of the language they sit on top of. ↩︎
Of course, the other major reason is that C++, the STL, and most major C++ libraries are happy to cause unchecked UB at the slightest provocation, and have an unfortunate habit of the easiest and/or most obvious way of accomplishing something being the most UB prone. ↩︎
I'm going to position C++ templates as weakly typed, as they error at compiletime upon instantiation with an insufficient type rather than at runtime, and don't allow any "properly" dynamic functionality like accessing properties not statically provided (i.e. from a subclass) but that happen to actually be present. But an interesting argument can be made that C++ templates are strictly-dynamic functionality rather than weakly-static. ↩︎
The main exception to this where C++ is more strictly typed than Rust is that C++ has typed memory (TBAA); if you initialize some (sub)object in memory (how doesn't matter), you are only allowed to mention that memory as types
std::byte,char,unsigned char, or with a "similar" type (i.e. to do so with any other type is UB), where a similar type roughly means the same type, another pointer type where the pointee type is similar, or another array type where the element type is similar, ignoring anyconst/volatilequalifications. Rust has exclusively untyped memory (althoughrepr(Rust)layout being unstable puts a giant caveat on what you can do soundly) and is technically weakly typed in that way. ↩︎The main exception to this is, of course, macros, which work exclusively in the domain of syntax and are unaware of types. A more interesting arguable caveat is method lookup rules (and even more arguably field lookup rules), since while type-directed, involves coercions and asking what is imported to local scope, meaning the same syntax with the same input types can have different results based on context. ↩︎
For complete fairness, you can eliminate coercions from a dialect of C++ by abandoning (most of) the STL and wrapping the primitives in a nominal struct type that doesn't provide implicit constructors. But I'm evaluating the language as it stands, and my main point is about templates anyway, whose weak typing (you're lauding and) can't be avoided. (Tag type dispatch can be and is used to mitigate the weak typing and achieve nominal (as opposed to structural) generic functionality like is provided by Rust's traits, but can't fully avoid templates' instantiation-time deferral of typechecking.) ↩︎
Exempting those that were a "simple" nullptr deref in a dependency that should have been guarded against (and was in a latter version we weren't updated to) and was only difficult to diagnose because of symbol stripping in the production configuration but not having the issue (or complaining in any way) in the development configuration where it would've been much easier to diagnose. (Yes, I'm still salty at Major Company™ for shipping that heisenbug in Notable Production Software™ and not fixing it in the patch release (that we were using) released in tandem with the feature update that happened to fix it.) I'm lucky enough not to have run into any use-after-free style issues in C++ due to maintaining a Rust-like scoped ownership practice in the C++ I've done. ↩︎
Because C++ is powerful enough that someone downstream could essentially always implement a type(s) specifically designed to satisfy the requirements for successful template instantiation. You could probably manage to construct an impossible instantiation by asking for coercion-resistant
std::is_same_vto two different types, but I'm making an assumption that the template is to be uninstantiatable by accident, not by design. You could check that template substitution only relies on required concepts, but I don't believe continuing through typechecking would ever be practical because you can't predict explicit template specialization. ↩︎Concepts were supposed to improve this for C++20, and they do, somewhat. They're still purely structural, so they can only check that a type syntactically satisfies a concept, not that it semantically models it, and the actual templates are still entirely structural, but it still provides a benefit, since instantiation errors can say "why" and point "blame" earlier in the template stack, so long as concept requirements are propagated. Unfortunately, most compilers currently swing too far in that direction, and only say that the concept isn't satisfied, and not how (i.e. what's missing from the concept), so if a type is supposed to model a concept but accidentally is missing some requirement of satisfying it, you're left worse off than before. Additionally, because (IIUC) requires clauses place extra restrictions on template specialization that wouldn't be there without, it's at best difficult to utilize concept requirements for enhancing pre-C++20 template errors. These are addressable, but currently add to the friction between C++ and "if it compiles, it probably works" vibes. ↩︎
There is, of course, the caveat that downstream can of course implement safe traits however they want, to do whatever (safe) shenanigans they want. But programming (typically) isn't adversarial; downstream can fairly be assumed to be attempting to implement traits according to their contract. But without something like the nominal trait system to tie name and semantic together, name collisions can and do occur accidentally, because words can have multiple meanings, and even then, meaning is contextual. ↩︎
There's a class of errors called "post monomorphization" errors that only occur after instantiation of generics. It's simple enough to test if an error is being caught post-mono: if it shows up in
cargo buildbut notcargo check. Rust does a reasonable job at preventing post-mono errors; originally, the only post-mono errors (notable enough for me to know about them) were "type to large for this target" and codegen/linker errors (i.e. after rustc has done its work). More recently (but still fairly old) it became possible to panic inconsts, and using a generic erroneousconstin a generic context is a currently a post-mono error. The stabilization ofconstblocks is being discussed, and will make such generic const panics more accessible; as part of that discussion it's being discussed how much it would cost to bring these errors forward so they're no longer monomorphization-dependent (occur incheckbuilds) but still instantiation-dependent. ↩︎The general tradeoff is that with an inferred return type, it's the exact, fully specific type bound that's actually being returned; but with a specified type, it's an API contract between caller and implementation, but may conservatively overshoot the actual returned type (i.e. the caller may be required to handle cases that are actually unreachable). ↩︎
In extra dynamic languages like bash, even syntax errors don't happen until execution crosses over it! And even in dynamic languages which parse the entire file before executing it like Python, imported files typically aren't parsed until the import statement is executed. ↩︎
Notably, this means that, unlike functionlike macros, control flow (e.g.
?,return) is bounded by the function, that (final) monomorphization of the code happens at most once (per codegen unit) for each instantiation set, and that the code is subject to standard inlining heuristics instead of being unavoidably semantically inlined to every callsite. ↩︎There's an experimental feature enabling upstream to share its generic instantiations with downstream. However, even as it gets rolled out to stable, it can only help for instantiations that actually exist (so you probably still want the macro to splat them out over the closed set of common impls), and it can't help for sibling crates, only dependent crates. ↩︎