Skip to content

Latest commit

 

History

History
166 lines (122 loc) · 6.21 KB

selective-argument-application.md

File metadata and controls

166 lines (122 loc) · 6.21 KB

Selective argument application

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.

Application

Applicator does application, and C++17 offers two generic applicators:

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.

Filtering

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 bools 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();
}

Lifting

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.

About this document

March 3, 2018 — Krzysztof Ostrowski

LICENSE