4
\$\begingroup\$

I've been looking at getting back into C++11 after a few years and came across the idea of implementing the 'inline' visitor pattern. The idea is that you can specify your visitor class locally in the code using lambdas, function pointers and/or std::function. Missing functions would default to a default implementation (D in the code below). I would be interested in the opinion of any C++11 gurus out there about improvements that I can make, or if there is a much better way to do this. Over time I'd like to formalize this simple idea into a much better library, so any contributions would be welcome.

My questions:

  1. Is there a better way to extract the type of the first argument of the function that using my arg_type template
  2. Is there a better way of passing in the default visit implementation other than as a functor in a template template argument?
  3. To me having the nodes reference an interface of the visitor exclusively for their type (ivisitor<mynodetype>) seems sensible, but usually the visitor pattern doesn't separate the visit() implementations into separate interfaces, so I'm wondering if there is a downside to this idea?
  4. Can I avoid storing my visit() implementations in a std::function datamember?

GitHub

#include <functional>
#inline-visitor.hpp

template<typename T>
struct arg_type :
  public arg_type<decltype(&T::operator())> {};

template<typename T>
struct arg_type<void(*)(T&)> : 
  public arg_type<void(T&)> {};

template<typename T, typename C>
struct arg_type<void(C::*)(T&) const > : 
  public arg_type<void(T&)> {};

template<typename T>
struct arg_type<void(T&)> {
  typedef T type;
};

template<typename T>
class ivisitor {
  public:
    virtual ~ivisitor() = default;
    virtual void visit(T& node) = 0;
};

template<typename T, template<typename> class D> 
class visitor : public ivisitor<T>{
  public:
    visitor():
      f_(D<T>()) {
    }
    void visit(T& node) override {
      if(f_) {
        f_(node);
      }        
    }
    void set(std::function<void(T&)> f) {
      f_ = f;
    }
  private:
    std::function<void(T&)> f_;
};

template<template<typename> class D, typename ...T>
class compound_visitor : public visitor<T, D>... {
  public:

    compound_visitor() = default;

    template<typename ...F>
    compound_visitor(F... f) {
      set(f...);
    }
    template<typename F, typename ...Fs>
    void set(F f, Fs... fs) {
      set(f);
      set(fs...); 
    }

    template<typename U>
    void visit(U& node) {
      this->visitor<U, D>::visit(node);
    }

    template<typename F>
    void set(F f) {
      this->visitor<typename arg_type<F>::type, D>::set(f);
    }
};

template<typename C, typename ...F>
auto make_compound_visitor(F... f) -> C {
  return c(f...);
}

Example code follows:

main.cpp

#include "inline_visitor.hpp"
#include <iostream>

template<typename T>
struct output_default {
  void operator()(T&) {
    std::cout << "default";
  }
};

class node1 {
public:
  void accept(ivisitor<node1>& v)   {
    v.visit(*this);
  }
};
class node2 {
public:
  void accept(ivisitor<node2>& v)   {
    v.visit(*this);
  }
};

class node3 {
public:
  void accept(ivisitor<node3>& v)   {
    v.visit(*this);
  }
};

class node4 {
public:
  void accept(ivisitor<node4>& v)   {
    v.visit(*this);
  }
};

int main(int argc, char** argv) {
typedef compound_visitor<output_default, node1, node2, node3, node4> concrete_visitor;
  auto v = make_compound_visitor<concrete_visitor>(
    [](node1& node) -> void { std::cout << "n1";},
    std::function<void(node2&)>([](node2& node) -> void { std::cout << "n2";}),
    +[](node3& node) -> void { std::cout << "n3";}
  );

  node1 n1;
  node2 n2;
  node3 n3;
  node4 n4;

  n1.accept(v);
  n2.accept(v);
  n3.accept(v);
  n4.accept(v);

  return 0;
}
\$\endgroup\$
1
  • \$\begingroup\$ I guess one response to Q3 is that normally the nodes would be stored in some homogenous list and processed iteratively, requiring them to share a common interface. \$\endgroup\$ Commented Jan 17, 2018 at 6:18

3 Answers 3

3
\$\begingroup\$

Inheritance

I believe that inheritance based OOP is painful to use in C++ compared to languages like java. In my opinion, static polymorphism is better. Runtime polymorphism is hard to do because C++ has manual memory (and ownership semantics in general) and local variables are strongly typed.

Use cases

Personally I can't see a use case for visitor. They might be useful in conjunction with std::variant, but otherwise I fail to see it being useful.

Too much runtime

If a function is virtual, there is no need to store it in std::function. Too many indirections, and the last one has type erasure, which is heavier mechanism than runtime polymorphism.

Static way

Visitors are usually used to get double dispatch. Templates provide N way dispatch, e.g. the type will keep propagating as long as the call chain is all templated. Lets look at this interesting example from std::visit documentation page.

template<class... Ts> struct overloaded : Ts... { 
    template <class... Fs> overloaded(Fs&&... fs) : 
        Ts{std::forward<Fs>(fs)}... 
    {}
    using Ts::operator()...; 
    
};
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;

This requires C++17, but not hard to emulate using C++14, using make_overloaded.

Now, the usage:

    std::visit(overloaded {
        [](auto arg) { std::cout << arg << ' '; },
        [](double arg) { std::cout << std::fixed << arg << ' '; },
        [](const std::string& arg) { std::cout << std::quoted(arg) << ' '; },
    }, v); // v is std::variant

As you can see, types are actually inferred, so the need for std::function just goes away. Usually, templates are good way to go unless it is some highly dynamic environment like GUI, where I would pick Qt framework or, for a language, python, or any other duck typed language.

\$\endgroup\$
4
  • \$\begingroup\$ Really interesting points. I shall digest these and dig more into std::variant as I am not so familiar with it \$\endgroup\$ Commented Jan 17, 2018 at 6:24
  • \$\begingroup\$ The overloaded class doesn't work without defining a constructor as follows: c++ template <class... Fs> overloaded(Fs&&... fs) : Ts{std::forward<Fs>(fs)}... {} \$\endgroup\$
    – marton78
    Commented Jan 18, 2023 at 12:51
  • \$\begingroup\$ @marton78, thank you, added. The formatting is a bit challenging, but hopefully readable. \$\endgroup\$ Commented Jan 21, 2023 at 23:04
  • 1
    \$\begingroup\$ A good way to do runtime polymorphism with automatic memory management in C++ is std::unique_ptr<base_class>(new derived_class()). If you’re sure you need to copy instead of move, std::shared_ptr<base_class>. \$\endgroup\$
    – Davislor
    Commented Jan 22, 2023 at 3:57
1
\$\begingroup\$

@Incomputable already gave a great answer; you're likely better off using generic programming instead of inheritance.

Simplify creating visitable classes

In your example main.cpp you had to add an accept() member function to all the classes you want to be visitable. Assuming you want to stick with the inheritance approach, you can simplify this by creating a class visitable that node1 etc. inherit from. You can do this by using the curiously recurring tempate pattern:

template<typename T>
class visitable {
public:
    void accept(ivisitor<T>& v) {
        auto self = static_cast<T*>(this);
        v.visit(*self);
    }
};

And then you can write:

class node1: public visitable<node1> {};
class node2: public visitable<node2> {};
class node3: public visitable<node3> {};
class node4: public visitable<node4> {};

Unnecessary use of this->

You almost never have to write this-> in C++. You can remove your use of this-> and the code should still work.

What about visitors that want to return a value?

A big drawback of the inheritance approach is that the accept() function is quite limited: it only accepts visitor functions that return void. What if you wanted to visit nodes with some function that should return a value? You can make that work by adding a template parameter for the return type. Note that this is actually trivial when using std::visit().

Other approaches

I just want to mention that C++ is quite flexible, and there are other approaches to writing visitors. Especially with later versions of C++, you can write a visitor like so:

template<typename T>
void visit_node(T& node) {
    // Do some stuff not dependent on the type of node
    …

    // Do something that depends on the type of node
    if constexpr (std::is_same_v<T, node1>) {
        std::cout << "node1\n";
    } else if constexpr (std::is_same_v<T, …>) {
        …
    } else {
        std::cout << "default\n";
    }

    // Do some more stuff not dependent on the type of node
    …
}

This is especially useful if you have a visitor function that has a lot of common code that does not depend on the type of the object you are visiting, so only the parts that need to specialize can use if constexpr, std::visit, or even use your visitor approach.

\$\endgroup\$
0
\$\begingroup\$

looks interesting! I'd be interested in the details. Have you seen this?

https://accu.org/index.php/articles/2021

Missing functions would default to a default implementation

If you have a closed set of types, I wouldn't recommend missing/default implementations for visited types. One benefit of visiting is that if you add a new type you are forced to write missing implementations for all the visitors without overlooking any.

\$\endgroup\$
1

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.