
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