“type erasure” to “type tunneling”
As we recognized the subtile differnce between “tyoe erasure” and “type tunneling”, std::any came tou our mind. As simple at it is, it is nevertheless the prototypical “type tunnel”. That is also all you can do with it. But with some love, that can be a lot.
To show you a litte example, lets immagine a simple “DB-System”. An application can register factories for “types”. The Types are identified through a str::string. The factory gets a second string paramter, containig serialized date for that type.
https://github.com/andreaspfaffenbichler/virtual_void/blob/master/test/05_Sink_TypeErased_w_any_cast.cpp
An application can attach a “file” to that DB-System. That is a list of pairs of strings. first cantains the type and second the data of a serialized object. When the Application queries the known objects of the file, the db calls for each pair the fitting factory and passes afterward the constructed object to the application. In this first implementation, we will use std::any to hold the deserialized object. To access the real object, we must use any cast, to test if we know the tunneled type, and if so we get the object for further processing.
Just for illustration purposes we take also a short look on an OO-Style implementation of this scenario. https://github.com/andreaspfaffenbichler/virtual_void/blob/master/test/03_Sink_Inheritance_modern.cpp
If we compare the two distict strategies, we see, the have the downcast pattern in common. Form our type tunneling perspective, we can say the inherritance, the v-table and the “dynamic_cast” work together to form a “type tunnel”. If we look from the aspect of coupling, we see a difference. All the application classes are coupled to the IAny base. But after the “upcast” to “Data”, the v-table run time dispatch decouples the types in the application layer-
void ReportSink(const DB::IAny* any) {
if (auto data = dynamic_cast<const Data*>(any))
std::cout << data->ToString() << std::endl;
}
With std::any have no dependency in the classes, but we have a strong coupling in the function “ReportSink” to all know types in the Application. With the OO is only a depenency to the highest abstraction level “Data”. This seams fine, because we look at only one sink for one query. Are thre many query sinks, and you want stay at this patter, one virtual function will not be enough and you will add virtual functions proprtional to the different sink you hav to serve with “Data”.
From this perspective, that that resembles the visitor pattern. Visitor pattern is notorious for discomfort. Interestingly, for the any case, there is a solution, that jummps in your eye: Do the dispatch via typeid!
The first naiive aproach will look like this: test/05_Sink_TypeErased_w_any_dispach_simple.cpp
From the standpoint of decoupling is this the best you can get. But this comes with a large runtime penalty… Nevertheless, this aproach looks to appealing. So will in the next step investigate, how big is the overhead we generate with map, function and any in comparison to plain v-tables. For this we will use another example: An expression tree! In OO-style ths goes like this: https://github.com/andreaspfaffenbichler/virtual_void/blob/master/test/20_Tree_OO.cpp We will use the expression tree to compute its value and to show a representation in forth and in lisp. With a “catch” benchamrk we watch the runtime performance.
Now to the sample with our [naiive any dispatch] (https://github.com/andreaspfaffenbichler/virtual_void/blob/master/test/21_Tree_TE_dispatch_via_any.cpp)
On my laptop, i got this number with standard releas mode and MSVC 17
-------------------------------------------------------------------------------
20_Tree_OO
-------------------------------------------------------------------------------
D:\BitFactory\Blog\virtual_void\test\20_Tree_OO.cpp(63)
...............................................................................
benchmark name samples iterations est run time
mean low mean high mean
std dev low std dev high std dev
-------------------------------------------------------------------------------
20_Tree_OO value 100 2797 1.3985 ms
4.56024 ns 4.52199 ns 4.63389 ns
0.264757 ns 0.15925 ns 0.390666 ns
20_Tree_OO as_lisp 100 148 1.5244 ms
92.6486 ns 92.1149 ns 93.5676 ns
3.48399 ns 2.35465 ns 6.28842 ns
-------------------------------------------------------------------------------
21_Tree_TE_dispach_via_any_dispatch
-------------------------------------------------------------------------------
D:\BitFactory\Blog\virtual_void\test\21_Tree_TE_dispatch_via_any.cpp(88)
...............................................................................
benchmark name samples iterations est run time
mean low mean high mean
std dev low std dev high std dev
-------------------------------------------------------------------------------
21_Tree_TE_dispach_via_any_dispatch
value 100 230 1.518 ms
55.1478 ns 54.8478 ns 55.8826 ns
2.2301 ns 1.13441 ns 4.53223 ns
21_Tree_TE_dispach_via_any_dispatch
as_lisp 100 113 1.5255 ms
125.407 ns 123.372 ns 128.301 ns
12.3265 ns 9.77748 ns 17.1408 ns
The value case shows the overhead cler. Not a surprise computers today are realy fast the in first rules of arithmetic, and the time neccessary to find the right function to call is a big part of the whole runtime. As soon as there is a little relevant work todo in the found function, the time neccessary for dispatch is a smaller fraction. The overhead goes down from ~1.400% to ~30%.
30% overhead is still a lot, but worth a consideration, if it could help to escape visitor pattern hell or to get a hot header file out of the build time bottleneck. We have not even tryed to speed up the things, so that’s what we have to do next!