Advanced Placeholder Replacement in C++: Handling Dynamic Data with Templates and Type Erasure
Simone Palacino
Posted on May 8, 2024
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}}"
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));
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}},
});
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."
Solution Overview
We'll tackle this challenge by creating a system that uses:
- Variadic Templates: To accept any number and type of parameters.
-
Type Erasure with
IPlaceholder
Interface: To manage heterogeneous types in a uniform way. -
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;
};
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);
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_;
};
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); });
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])...);
}
};
I want to draw your attention to how I change the resolve
method.
The two important steps here are:
- I want to expand the arguments that I have in a vector, to be passed to the
resolver_
function; - 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 theArgs
(the variadic template argument of the class):
resolver_(args[I]...);
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])...);
So, we need to pass to this function the vector of std::any
s 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])...);
}
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...>{});
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();
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...>{});
}
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:
-
addPlaceholder(placeholder)
: The initial steps to set all the placeholders; -
replacePlaceholders(input, args)
: When we actually want to perform the tokens; -
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};
};
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; }));
}
};
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:
- For the
Placeholder
implementation there isn't a static type check at compile time for the vector ofstd::any
and the actual types of the callback; - For
PlaceholderManager
theresolve
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; - 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.
Posted on May 8, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.