![]() |
Boost : |
From: Jean-Louis Leroy (jl_at_[hidden])
Date: 2025-05-08 05:32:51
Hi Andrzej,
Thank you for the review.
> The library claims to solve two problems: the "expression problem" and the
> "banana-gorilla-jungle problem". In my opinion, the first problem is not a
> technical problem worth solving at all.
> [...]
> Jean-Louis provided a very compelling example of printing a Matrix which is
> from a OO hierarchy
Instead of "expression problem", it can also be "marketed" as a way to deal with
"cross-cutting concerns". The matrix example, or the rendering of an AST as a
RPN string, are both cross-cutting concerns.
> 1. Use OO-style class hierarchies with pattern-matching on top: either wait
> for the C++ pattern matching, or use a library like Mach7 (
> https://github.com/solodon4/Mach7)
I would like to see both open-methods and pattern-matching in the language; and,
in the meantime, in Boost. They exist in the same broad space, overlapping to an
extent, yet each brings solutions that the other does not.
Mach7 and the related papers contain a benchmark that shows that
pattern-matching is slower than type-switch, which is slower than open-methods.
But I reckon that, in presence of a single value subject to dynamic dispatch,
pattern-matching is not horribly slower at all. I guess that, in many real
programs, the difference would be unmeasurable.
> It is not clear to me what is the relation between Boost.OpenMethod and
> N2216. Is the goal to implement N2216? Or is it just mentioned for context,
> or as a loose inspiration?
It started as a strong inspiration. I never implemented the attempt to resolve
ambiguities at all cost in YOMM2. I did not implement covariant return types as
a tie-breaker, which has been there from the very first paper, because I had
reservations about it. The vocabulary (method declarations and method
definitions) I use in YOMM2 is inspired more by CLOS and Clojure than by N2216.
For OpenMethod, I thought I could get closer to consensus if I brought the
library closer to N2216. In a way I did get a consensus: everybody, including
myself, seems to strongly dislike "ambiguity is not an error".
I already had some disagreements with N2216 (more below) and they grew as the
review progressed.
I am now strongly leaning towards making the YOMM2 way the default way of
handling ambiguities, with N2216 an opt-in.
> But note that N2216 calls itself "a report" and the authors never claimed that
> they had a solution suitable for standardization. It was an exploration.
I see it quite more than that. It is the last of a series of papers describing
essentially the same design, with small variations over time. But here we are
just trying to guess what is in other people's heads.
> But the solution in N2216 has one important property: the program would never
> get a "runtime error" indicating a bad usage of the feature: it would either
> be a compiler/linker error or an arbitrary -- but *deterministic* -- selection
> of the overload.
Ambiguity handling in OpenMethod is deterministic as well. And just like in
N2216, which overrider is picked is undocumented.
> N2216 allows no "pure virtual" open methods: it requires that you provide a
> definition for the "top level" declaration. This way you always have an
> implementation to fall back to.
This is one of the places where I dare disagree with our three luminaries ;-)
You have to look hard to find an *example* of a base-method implementation (what
you call a "top level declaration").
In N2216:
bool intersect(virtual Shape&, virtual Shape&) { }
This doesn't look good, it silently returns... what by the way? Isn't this UB?
It's a warning at least. Anyway this will make it harder to troubleshoot a
missing overrider.
In Solodkyy's paper "Simplifying the Analysis of c++ Programs":
int eval (virtual const Expr&) { throw std::invalid argument ("eval"); }
This is better. I think that it is what most base-method implementations will
look like. Again and again. A counter-example is my "animals meet" example: they
ignore each other. But it's a made-up example. Reasonable fallback
implementations will happen, but they will not be the most frequent.
I have the feeling that Stroustrup & co wanted to avoid runtime errors so badly,
while also refusing to throw an exception (I'm with them on this), that they
convinced themselves that this is the right design.
My take is that this is the same situation as calling a pure virtual function.
You get a short message, if any, and then abort(). And there is no way to detect
such a call through static analysis of a single translation unit.
In OpenMethod, you can look at the report member returned by initialize() and
find out, early, that you have virtual tuples without overriders.
I could implement the N2216 way. What would it get us? Again, users would write
silly catch-alls like the above. And then checking the report would be
pointless, because I can detect missing overriders, but not silly ones.
I am changing my mind about something though. In release variants, by default, I
should output a diagnostic. You could opt out via a policy facet if you want
minimal footprint.
Above is my position on *missing* overriders, which I implemented. Indeed that
is not the N2216 way.
The N2216 part I implemented when transitioning from YOMM2 to OpenMethod is how
*ambiguities* are handled, i.e. bring that part is in line with N2216. I don't
see the difference between OpenMethod and N2216. As long as you don't dlopen() a
library that adds methods, overriders or classes, one of the ambiguous
overriders is called, unspecified but always the same. Call dlopen(), and
everything is reshuffled. The only difference is that, OpenMethod being a
library, you have to call initialize() again.
Since we're at it, I also have doubts about using covariant return types as
ambiguity tie-breakers. It's a conditional mechanism. It kicks in only if the
return types are classes. It's getting too complicated. When I present YOMM2, I
say: "I don't need to explain the resolution rules, you already know them. Same
as overload resolution, only it's at runtime". I find it more coherent, less
surprising.
> It is a common practice for library functions to have *preconditions* and
> to enforce them by calling std::abort() or throwing an exception, but this
> works only because the users have the means of controlling the correct
> usage of a function:
>
> sqrt(x);
>
> And I could have checked before the call that `x` is negative, or I could
> have made sure that `x` is the result of summing squares. Because I am in
> control. But in this call:
>
> to_string(matrix);
>
> I am not in control of satisfying a precondition, because I do not control
> what other matrix classes have been added to the hierarchy by other users
> in other DLLs that I may not even be aware of. This is why exceptions or
> std::abort() are not a good solution anymore. It is no longer a viable
> precondition.
This is a criticism of open-methods in general, correct?
> Another difference between Boost.OpenMethod and N2216 is that in the latter
> a programmer never says that they are overriding a virtual method.
Doesn't it? It's BOOST_OPENMETHOD and BOOST_OPENMETHOD_OVERRIDE. It's more
explicit than the N2216 syntax. Which of course I cannot reproduce, because it's
a library.
Here's another - a better? - one. In N2216, an overrider can even override
multiple base-methods. I cannot emulate that. And anyway, what does `next` mean
in that case?
> Another difference is that N2216 makes it an error to define a parameter as
> virtual when a corresponding class s not a reference to a polymorphic type.
> This is a feature (a safety feature). Boost.OpenMethod does not have it: I
> can pass any type I want in virtual_ptr only to get an abort at run-time:
> https://godbolt.org/z/b1Y5zMcfh
We discussed this before, a compile-time check used to exist in YOMM2. It was
lost when I added support for custom RTTI and non-polymorphic types. Following
your remark, I re-introduced the compile-time test in the "review" branch.
> I would expect a way more of a discussion and explanations in the
> documentation. More concepts and definitions. The lack of those, makes me
> further question the soundness of the design.
The documentation needs improvement, right. I wouldn't drown "normal" users with
information on advanced features though. It's not an easy piece to document.
The design gives you (or whomever wants it) a flexibility that N2216 doesn't.
You can work with non-polymorphic types. Or rather, they will be polymorphic
(because open-methods are), without requiring any virtual functions in your
hierarchies.
> For one instance, the statement, "Multiple and virtual inheritance are
> supported, with the exception of repeated inheritance." What does it mean
> "not to support a form of inheritance"? Compiler error? Run-time abort()?
> Undefined Behavior?
A compile-time error. Consider:
#include <boost/openmethod.hpp>
#include <boost/openmethod/compiler.hpp>
#include <iostream>
struct Repeated {
virtual ~Repeated() {}
};
struct Base1 : Repeated {};
struct Base2 : Repeated {};
struct Derived : Base1, Base2 {};
BOOST_OPENMETHOD_CLASSES(Repeated, Base1, Base2, Derived);
BOOST_OPENMETHOD(wrong, (virtual_ptr<Repeated>), void);
BOOST_OPENMETHOD_OVERRIDE(wrong, (virtual_ptr<Derived>), void) {
std::cout << "Derived\n";
}
int main() {
boost::openmethod::initialize();
Derived d;
Repeated& r = d;
wrong(r);
}
https://godbolt.org/z/6bvTahhqb
Thank you for pushing on this though. Having thought about it, I am
pretty confident
that I can detect this situation in use_classes/BOOST_OPENMETHOD_CLASSES and
generate a better error message.
> Earlier, I made a claim that the authors of N2216 dropped the idea in favor
> of pursuing pattern matching. I cannot find any citation that would support
> this. I guess I got that from the fact that two of the authors of N2216 --
> Bjarne Stroustrup and Yuriy Solodkyy -- later wrote papers only on pattern
> matching (wg21.link/n3449,
> https://www.stroustrup.com/OpenPatternMatching.pdf). Maybe I got this from
> the ISO C++ mailing lists.
Stroustrup:
> In retrospect, I donât think that the object-oriented notation (e.g., x.f(y))
> should ever have been introduced. The traditional mathematical notation f(x,y)
> is sufficient. As a side benefit, the mathematical notation would naturally
> have given us multi-methods, thereby saving us from the visitor pattern
> workaround.
> Stroustrup, 2020, Thriving in a Crowded and Changing World: C++ 2006â2020.
I am not a mind-reader, but it seems to me that he still liked open-methods as
of 2020, some years after the pattern matching papers.
> If I understood correctly, the idea behind virtual_ptr is that it should be
> created early to front-load the expensive hash table look-ups.
Yes. It's one of the ways of using the library. You can also use an embedded
vptr (see with_vptr).
There is an ongoing debate about implementing virtual functions the C++ way
(embedded vptr) vs the Go/Rust fat pointer. I believe that in practice it cannot
be decided either way. Anyway...
> This tells me that even though I have to use a lot of macros in my
> interfaces, they are still not able to hide the implementation details, and I
> am exposed to these details through the pieces of advice, such as "pass around
> virtual_ptr and store them as members".
virtual_ptr is not an implementation detail. It's an interface. While turning
YOMM2 into OpenMethod, at one point I decided to bring virtual_ptr - an
afterthought in YOMM2 - to front stage. One of my motivations was
clarity. If you
use virtual_, you use it in the base-method declaration, but not in the
overriders. That was confusing.
Also note that virtual_ptr at all levels resembles N2216 syntax more.
> Then I am exposed to more things, like final_virtual_ptr
It is an optimization (similar to make_shared) that gives open-methods the same
speed as native virtual functions. It just loads the vptr from a static
variable. When an object is created, we don't have to find its type, we know it.
Why not give the *option* of using that knowledge?
> (not to mention unique_virtual_ptr and shared_virtual_ptr).
Copied from my reply to Joaquin on April 30.
> "Design and evaluation of C++ open multi-methods", 5.3. Smart pointers states:
>
> > Defining open-methods directly on smart pointers is not possible. In the
> > following example, (1) yields an error, as ptr1 is neither a reference nor a
> > pointer type. The declaration of (2) is an error, because shared_ptr is not
> > a polymorphic object (it does not define any virtual function). Even when
> > shared_ptr were polymorphic, the open-method declaration would be
> > meaningless. A shared_ptr<B> would not be in an inheritance relationship to
> > shared_ptr<A>, thus the compiler would not recognize foo(virtual
> > shared_ptr<B>&) as an overrider.
> >
> > void foo(virtual shared_ptr<A> ptr1); // (1) error
> > void foo(virtual shared_ptr<A>& ptr2); // (2) error
>
> I don't buy this. How is it different from plain pointers? A Cat* is not in an
> inheritance relationship to Animal* either: Cat and Animal are, thus there is
> a standard _conversion_ from one to the other.
>
> My intuition is that virtual smart pointer arguments must be forbidden because
> it would require the compiler to be aware of the standard library.
I don't want to over-quote myself, please see my full answer in that post.
> Now, I have a more philosophical question. [...] Every library or a language
> feature or a tool is a burden: you have to learn it, your users have to learn
> it, you need to debug it. In order for it to be useful, the costs of
> usage/maintenance must be outweighed by the benefits of using it. I do not see
> this being the case for Boost.OpenMethod.
> This is my main objection.
I have the impression that these paragraphs, while interesting, are general
considerations about the principle of open-methods, and the value of libraries;
and only in the last sentence you connect them to OpenMethod.
I don't perceive Boost as a monolithic message on how programming should be
done. I see it more as a toolbox, full of *good* *quality* tools, and it is up
to users to pick what tool they like to use. Or not.
> * Things are imported into a global namespace (such as virtual_ptr). Don't
> do it by default. If users want to see virtual_ptr and final_virtual_ptr as
> keywords, let them type `using namespace boost::openmethod::keywords;`.
I won't die on that hill. This has been suggested earlier in the review, and I
considered it (positively) before the review even began. Of course I realise
it's borderline. Please consider it done.
> * The framework behaves differently based on whether my types are copyable
> or not. Compare:
> ** https://godbolt.org/z/zKzYzbj3x
> ** https://godbolt.org/z/Ya8TrE79K
There are a few things going on here.
What you should get is a runtime error: unknown class YNode
And that is the root of the problem. *Not* constructors.
So what's happened?
1. You are compiling with NDEBUG, so you get no checks, and no diagnostics. That
is a mistake on my part. I focused too much on the smallest possible
footprint in release builds. I will change it to: diagnostic by default, with
the possibility to opt out.
2. In the conversion from YOMM2, I introduced a bug. Fixed in the "review"
branch, see https://godbolt.org/z/6v8v5ce4b
> * I spent a couple of days in total, studying the docs, re-reading related
> papers, running examples, filing bugs, and demonstrating the library in my
> workplace.
I am curious, what were the reactions?
> Finally, this review may seem very negative, so let me stress again that I
> appreciate the work Jean-Louis has done to explore the subject. A critique
> can be seen as a form of appreciation.
I got valuable feedback, which triggered new ideas, in particular about the
customization points, and the defaults (like, emit diags even in release mode by
default, N2216 and ambiguity handling). And I still have to read Steven
Watanabe's review...So I am happy I took the plunge.
One final thing: upon re-reading my reply, I realize that it looks like I am
willing to change my mind on a lot of things. Should I be sent back to the
drawing board then? Am I shooting myself in the foot?
So:
* I am always open to changing my mind, given good arguments. I also know what
my goals are though.
* This is the first time my work on open-methods gets a review. I had users and
even contributors for a decade. This time I get a review and I find the
feedback very valuable. The conversations I am having on the policy/facet
system are guiding me to something better, simpler.
* Many of the "doubts" I expressed are related to picking the best *defaults*
* I intend my library to be production-ready. I also want it to enable research
and experimentation. The first motivation for policies was unit tests. Then it
was experimenting with memory layouts for dispatch data. Then hashing. And
finally, accepting that in the C++ world, there is also a reasonable need to
use custom RTTI; to disable exceptions; to operate with limited resources.
J-L
Boost list run by bdawes at acm.org, gregod at cs.rpi.edu, cpdaniel at pacbell.net, john at johnmaddock.co.uk