Consider a chain of actions that are going to be composed with each other in order to produce a result of a certain type. Once fed with an input, such a chain, either gives some output or signals computation failure. Generally, there is no insight into the data exchanged between those actions unless we inject a prepared action that intercepts the intermediate values (breaking the chain is not an option). Let's have a look at the chain that directly composes g
after f
, and for which we want to intercept the intermediate value using the prepared action l
:
struct F { T operator() (U, V); };
struct G { T operator() (T); };
F f;
G g;
(void) g( f(u, v) );
// ^~~ intermediate value is here
(void) g(l(f(u, v)));
// ^~~ intercepts the result of f
— where l
can be either a generic lambda expression or a function object with the signature auto (auto&&)
. Action l
forwards the received input value (result of the f
application) transparently to g
). Extra work performed by l
includes "recording" of the value that is forwarded. Recording means here storing a value in the external container (like logger stream) for later processing.
Let's design an interceptor that records values of the selected arguments passed to any action transparently, then applies that action to the captured arguments, and eventually passes the result value to the next action.
Applicator does application, and C++17 offers two generic applicators:
std::invoke(F, Args...)
for direct application off
to a number of arguments, and...std::apply(F, tuple<Args...>)
that unpacks arguments from the giventuple
container (aka "explosion") and performs the application using the former one applicator.
Here is the "interceptor" function object that works for any action of type F
:
template<class F>
// requires Callable<F>
struct Middleman
{
template<class... Args>
decltype(auto) operator() (Args&&... args)
{
// *** record the values of the selected arguments ***
return _f(std::forward<Args>(args)...);
}
constexpr Middleman(F& f) : _f(f) {}
std::reference_wrapper<T> _f;
};
Middleman
can be understood as a function with the signature auto (F&, auto&&...)
that is partially applied to an object of type F
by means of its constructor. Example usage:
F f;
G g;
M m{f}; // partial application
(void) g( m(u, v) );
// ^~~ f replaced with m
The missing part is the recorder invocation. The record
action, that does the actual recording, only accepts values of types for which some predicate P
is satisfied. That is, we have to filter out the passed arguments' sequence before applying it to the record
action.
Let's define an example predicate as such that is only satisfied if the passed type T
is int
:
template<class T>
constexpr bool P = std::is_same_v<int, T>;
Given that, we can generate the "truth table", here modelled as bool
values wrapped into a heterogeneous container:
template<class... Args>
decltype(auto) qualify(Args&&... args)
{
return std::make_tuple(P<std::remove_reference_t<Args>>...);
}
// qualify(int, std::string, bool, int)
// gives (true, false, false, true)
Based on the sequence of bool
s produced by qualify
function we are able to filter out the uninteresting values – leaving the "true" ones untouched, and substituting the "false" ones with gaps. We have to produce a valid value in both cases. The easiest way is to wrap every value from the sequence into a container that is allowed to have an empty state, and then flatten the produced abstraction (astute reader will see a Monad here). In the following chart, the ()
denotes such a container:
(int, std::string, bool, int )
(true, false, false, true ) qualify
((int), (), (), (int)) wrap
(int, int ) flatten
Note that, we cannot use std::vector
as a container in the above example since it cannot hold values of different types at the same time (i.e. it is homogeneous). One of the most common valid choices here is std::tuple
, where std::make_tuple
does wrap
, and std::tuple_cat
does flatten
. With the help of std::apply
we "explode" the container that stores the intercepted arguments, and we pass those values directly to the record
function (by copy, ideally). Example:
template<class F>
// requires Callable<F>
struct Middleman
{
template<class... Args>
decltype(auto) operator() (Args&&... args)
{
(void) std::apply(record, std::tuple_cat(wrap(std::forward<Args>(args))...));
// ^~~ flatten
return _f(std::forward<Args>(args)...);
}
...
— where wrap
uses the P
predicate, and does:
template<class T>
constexpr decltype(auto) wrap(T&& t)
{
if constexpr (P<std::remove_reference_t<T>>)
return std::make_tuple(std::forward<T>(t));
else
return std::make_tuple();
}
We can easily derive a reusable abstraction from the presented in this article technique and call it apply_if
:
template<template<class> class P, class T>
constexpr decltype(auto) apply_if_impl(T&& t)
{
if constexpr (P<std::remove_reference_t<T>>::value)
return std::make_tuple(std::forward<T>(t));
else
return std::make_tuple();
}
template<template<class> class P, class F, class... As>
constexpr decltype(auto) apply_if(F&& f, As&&... args)
{
return std::apply
( std::forward<F>(f)
, std::tuple_cat(apply_if_impl<P>(std::forward<As>(args))...) );
}
Example of a selective application of arguments of types U
or V
solely follows:
template<class T>
using P = std::disjunction<std::is_same<U, T>, std::is_same<V, T>>;
auto result = apply_if<P>([](U, V, U) { return true; }
, float{}, U{}, V{}, bool{}, U{}, char{}); // true
See live code on Coliru.
March 3, 2018 — Krzysztof Ostrowski