DEV Community

Cover image for Advanced Placeholder Replacement in C++: Handling Dynamic Data with Templates and Type Erasure
Simone Palacino
Simone Palacino

Posted on

Advanced Placeholder Replacement in C++: Handling Dynamic Data with Templates and Type Erasure

Introduction

In many software development scenarios, especially in template engines, logging systems, or custom data processors, there's a frequent need to dynamically replace placeholders in strings with actual data. This can become complex when the data varies in type and quantity. Today, I'll show you how to elegantly handle this in C++ using templates, std::any, and type erasure techniques for a robust and type-safe solution.

The Problem

Traditional methods of replacing placeholders often rely on fixed formats or limited data types. However, modern applications require more flexibility and safety, particularly when dealing with various data types and an unknown number of parameters at compile time.

An example of the case and the result

The user wants to compose a message that have this template:

Hello {{name}}, great work today! You've taken {{steps}} steps and burned {{calories}} calories. Remember: "{{quote}}"
Enter fullscreen mode Exit fullscreen mode

We want to parse and replace with static or dynamic values, something like this:

PlaceholderManager pMgr;
  pMgr.addPlaceholder(std::make_shared<Placeholder<Person>>(
      "{{name}}", [](const Person& person) { return person.name; }));
  pMgr.addPlaceholder(std::make_shared<Placeholder<Person>>(
      "{{steps}}",
      [](const Person& person) { return std::to_string(person.steps); }));
  pMgr.addPlaceholder(std::make_shared<Placeholder<Person>>(
      "{{calories}}",
      [](const Person& person) { return std::to_string(person.calories); }));
  pMgr.addPlaceholder(
      std::make_shared<Placeholder<>>("{{quote}}", getRandomQuote));
Enter fullscreen mode Exit fullscreen mode

We can set up a PlaceholderManager to hold this particular token that we want to make available to the user. And then we can use this manager to pass the input to be parsed and the list of arguments to pass to the callback that is responsible to give the actual string for that placeholder.

Person person{73, "Sheldon", 370, 1072};
const std::string res =
    pMgr.replacePlaceholders(msg, {
                                      {"{{name}}", {person}},
                                      {"{{steps}}", {person}},
                                      {"{{calories}}", {person}},
                                  });
Enter fullscreen mode Exit fullscreen mode

And the result must be like this:

Hello Sheldon, great work today! You've taken 370 steps and burned 1072 calories. Remember: "Be a fan of anything that tries to replace human contact."
Enter fullscreen mode Exit fullscreen mode

Solution Overview

We'll tackle this challenge by creating a system that uses:

  1. Variadic Templates: To accept any number and type of parameters.
  2. Type Erasure with IPlaceholder Interface: To manage heterogeneous types in a uniform way.
  3. std::any: To store and pass parameters of different types dynamically.

Implementation

Our system starts with an interface IPlaceholder that all placeholder types will implement. This interface ensures that all placeholders can be managed polymorphically.

struct IPlaceholder {
  virtual std::string resolve(const std::vector<std::any> &args) const = 0;
  virtual const std::string &getPattern() const = 0;

  virtual ~IPlaceholder() = default;
};
Enter fullscreen mode Exit fullscreen mode

Each specific placeholder type is implemented using a template class that inherits from IPlaceholder. These classes can handle different types and numbers of arguments using variadic templates.

So here my Placeholder implementation, divided into small parts.
But we start with what we want to achive.
I want to be able to do something like this:

Placeholder ph("{{date}}", getSimpleDate);
Enter fullscreen mode Exit fullscreen mode

where {{date}} is my placeholder, and getSimpleDate a function or callback that is called to replace that token.

So the first implementation of the Placeholder class may be this:

class Placeholder : public IPlaceholder {
 public:
  using FuncType = std::function<std::string()>;

  Placeholder(std::string p, FuncType r)
      : pattern_(std::move(p)), resolver_(std::move(r)) {}

  const std::string &getPattern() const override { return pattern_; }

  std::string resolve(const std::vector<std::any> &) const override {
    return resolver_();
  }

 private:
  std::string pattern_;
  FuncType resolver_;
};
Enter fullscreen mode Exit fullscreen mode

In this case we export the pattern that we can use in a regex replacement, and calling the resolve we simply call the callback that was set in the constructor.

Now the problem is when we pass a callback that takes one or more arguments and returns the string to place where the token is. This will enable us to do some dynamic replacing.

Again, I want to be able to write something like this:

Placeholder userIdPh("{{userId}}",
                       [](const Person& person) { return std::to_string(person.id); });
Enter fullscreen mode Exit fullscreen mode

But how to modify our Placeholder class?
We need to add some templates for the arguments of the resolver, and a std::vector of std::any for the list of his arguments.

template <typename... Args>
class Placeholder : public IPlaceholder {
 public:
  using FuncType = std::function<std::string(Args...)>;

  Placeholder(std::string p, FuncType r)
      : pattern_(std::move(p)), resolver_(std::move(r)) {}

  const std::string &getPattern() const override { return pattern_; }

  std::string resolve(const std::vector<std::any> &args) const override {
    if (args.size() != sizeof...(Args)) throw ArgCountError();
    return invoke(args, std::index_sequence_for<Args...>{});
  }

 private:
  std::string pattern_;
  FuncType resolver_;

  template <size_t... I>
  std::string invoke(std::vector<std::any> const &args,
                     std::index_sequence<I...>) const {
    return resolver_(std::any_cast<Args>(args[I])...);
  }
};
Enter fullscreen mode Exit fullscreen mode

I want to draw your attention to how I change the resolve method.
The two important steps here are:

  1. I want to expand the arguments that I have in a vector, to be passed to the resolver_ function;
  2. I need to select the right element and cast it to the right type. So, to expand the arguments I start to write the resolver_ call with the expansion of the Args (the variadic template argument of the class):
resolver_(args[I]...);
Enter fullscreen mode Exit fullscreen mode

To select the index while I'm expanding the template I use the std::index_sequence<I...> that it will be created by the expansion of Args (std::index_sequence is a helper alias template of std::integer_sequence for the common case where T is std::size_t).

Now we add the casting to get back the right type of the argument from the std::any:

resolver_(std::any_cast<Args>(args[I])...);
Enter fullscreen mode Exit fullscreen mode

So, we need to pass to this function the vector of std::anys and the index_sequence:

template <size_t... I>
std::string invoke(std::vector<std::any> const &args,
                   std::index_sequence<I...>) const {
  return resolver_(std::any_cast<Args>(args[I])...);
}
Enter fullscreen mode Exit fullscreen mode

In the last, the resolve simply call the invoke method with the expansion of Args in order to create the index_sequence:

return invoke(args, std::index_sequence_for<Args...>{});
Enter fullscreen mode Exit fullscreen mode

I also choose to add a check of the size of the vect of args, with the number of the variadic template Args using the sizeof... and throwing an exception ArgCountError:

if (args.size() != sizeof...(Args)) throw ArgCountError();
Enter fullscreen mode Exit fullscreen mode
  std::string resolve(const std::vector<std::any> &args) const override {
    if (args.size() != sizeof...(Args)) throw ArgCountError();
    return invoke(args, std::index_sequence_for<Args...>{});
  }
Enter fullscreen mode Exit fullscreen mode

PlaceholderManager

Finally I introduced the PlaceholderManager class that keeps track of all placeholders and facilitates their replacement within strings. It matches placeholders to their data dynamically using std::regex and std::map.
I decided to escape the pattern, because like in our example above the tokens, e.g. {{name}}, use some character that must be escaped to be used in a regex. You can write your escape function, and let the user set his with the setEscapingFnct method.
The methods are:

  1. addPlaceholder(placeholder): The initial steps to set all the placeholders;
  2. replacePlaceholders(input, args): When we actually want to perform the tokens;
  3. setEscapingFnct().
class PlaceholderManager {
 public:
  typedef std::string(EscapingFnctTp)(const std::string &str);

  void addPlaceholder(const std::shared_ptr<IPlaceholder> &placeholder) {
    placeholders_[placeholder->getPattern()] = placeholder;
  }

  void setEscapingFnct(std::function<EscapingFnctTp> escapingFnct) {
    escapingFnct_ = escapingFnct;
  }

  // @param input The string to be modified.
  // @param args The map with vectors of arguments to pass to the functions of
  // that placeholder.
  // @return std::string The final string with all the placeholders replaced.
  // Exceptions: May throw SubstitutionError to indicate an error condition.
  std::string replacePlaceholders(
      std::string input,
      const std::map<std::string, std::vector<std::any>> &args = {}) {
    for (const auto &itPh : placeholders_)
      replaceEachPh(input, args, itPh.second);
    return input;
  }

 private:
  std::map<std::string, std::shared_ptr<IPlaceholder>> placeholders_;

  // Exceptions: May throw SubstitutionError to indicate an error condition.
  void replaceEachPh(std::string &input,
                     const std::map<std::string, std::vector<std::any>> &args,
                     const std::shared_ptr<IPlaceholder> &ph) {
    static const std::vector<std::any> empty{};

    const std::string &phStr = ph->getPattern();
    std::regex regex(escapingFnct_(phStr));
    auto it = args.find(phStr);
    const std::vector<std::any> &vArgs = it != args.end() ? it->second : empty;
    std::string fmt;

    try {
      fmt = ph->resolve(vArgs);
      try {
        input = std::regex_replace(input, regex, fmt);
      } catch (...) {
        throw SubstitutionError();
      }
    } catch (const ArgCountError &) {
    }
  }

 private:
  std::function<EscapingFnctTp> escapingFnct_{utils::escape};
};
Enter fullscreen mode Exit fullscreen mode

Extension of the PlaceholderManager

We can easly support our custom PlaceholderManager tailored to some functionality in our application. Add a predifined placeholder with corresponding function in the constructor.

class SimopPhMgr : public PlaceholderManager {
 public:
  SimopPhMgr() {
    addPlaceholder(
        std::make_shared<Placeholder<>>("{{date}}", getCurrentSimpleDate));
    addPlaceholder(
        std::make_shared<Placeholder<>>("{{iso8601}}", getCurrentIso8601));
    addPlaceholder(std::make_shared<Placeholder<SomeEvent>>(
        "{{eventName}}", [](const SomeEvent &event) { return event.name; }));
  }
};
Enter fullscreen mode Exit fullscreen mode

In conclusion

This advanced placeholder replacement system in C++ offers both flexibility and type safety, making it ideal for a wide range of applications where dynamic text processing is required. By leveraging modern C++ features, we can ensure robust and maintainable code.

But it is clear that there are some observations:

  1. For the Placeholder implementation there isn't a static type check at compile time for the vector of std::any and the actual types of the callback;
  2. For PlaceholderManager the resolve is called for all the placeholders, and only if you don't pass the args vector for that placeholder, both the resolve and the regex are skipped. But for example if you have a placeholder with a callback without arguments, the resolve and the regex are performed;
  3. Possibly we can pass also the return type to the Placeholder class for the resolver. If the conversion of that type to string is provided, automatically convert it to std::string (for example defining a template function for conversion, and providing template specializations of that).

So, there are many things to say about this simple but very effective implementation, and there are many ways to improve it.
I was thinking that if we want to achive the first observation about type check of the vector, we can use the tuples to pass the arguments.
Feel free to share your thoughts about this!

Here you can find the repo with a only header placeholder.hpp
gitlab.com/simopalacino/placeholderpp

Thanks for reading.

Top comments (0)