Sari la conținutul principal

Functor

In acest exemplu vrem să ilustrăm o capabilitate a limbajului C++ folosind concepte, anume să implementăm conceptul din teoria categoriilor numit functor. Cel mai apropiat exemplu de implementare de funtori în programarea funcțională este cel din Haskell unde un functor este un tip de date care implementează o funcție fmapfmap.

Definiția în Haskell este:

class Functor f where
fmap :: (a -> b) -> f a -> f b

Această definiție este foarte elegantă, dar în limbaje orientate pe obiect care suportă genericitate, explicitarea acestui concept în mod corect este foarte dificilă sau imposibilă. În C++20, în schimb, putem defini functorul ca un concept chiar dacă este mai dificil, nu este imposibil.

namespace sayten
{
template<typename FunctionT>
concept FmapParameter = Callable<FunctionT> &&
HasArgCount<FunctionT, 1> && !HasVoidReturn<FunctionT>;

template<typename InputT, typename FunctionT>
concept FmapRequirement = FmapParameter<FunctionT> &&
HasArgNoCVRefType<FunctionT, InputT, 0>;

template<template<typename> typename FunctorT>
concept HasFmap =
requires (FunctorT<placeholder<1>> functor)
{
{ functor.fmap(placeholder_function<0, 1>) } -> std::convertible_to<FunctorT<placeholder<0>>>;
{ functor.fmap(placeholder_callable<0, 1>{}) } -> std::convertible_to<FunctorT<placeholder<0>>>;
};

template<template<typename> typename FunctorT>
concept Functor = HasFmap<FunctorT>;
}

În codul prezentat sunt definite mai multe concepte pentru definirea conceptului de functor, acestea folosesc mai departe concepte definite în codul nostru de pe gitlab pentru a avea anumite capacități de reflecție statică în cod. Pentru cine este interesat, ne poate inspecta codul pentru mai multe detalii.

Prima dată am definit conceptul de FmapParameter, care să verifice dacă un tip este o funcție sau un obiect cu operatorul de apel prin conceptul Callable. Apoi, o valoare de acel tip trebuie să poată fi apelată cu un singur parametru asigurându-ne prin conceptul HasArgCount și să nu fie un apel rezultând void prin conceptul HasVoidReturn.

namespace sayten
{
template<typename FunctionT>
concept FmapParameter = Callable<FunctionT> &&
HasArgCount<FunctionT, 1> && !HasVoidReturn<FunctionT>;
}
notă

Orice obiect care are operatorul de apel poate fi apelat ca o funcție în mod implicit, care nu este altceva decât o metodă non-statică. Acest operator poate fi implementat de orice structură sau clasă în C++ și este folosit în spate de compilator pentru a implementa funcții lambda, care nu sunt altceva decât obiecte cu tip anonim și cu acest operator implementat. De asemenea, și clasele std::function au acest operator. Trebuie reținut că la crearea unei funcții lambda se creează de fapt un obiect care poate avea și un constructor. De exemplu, un lambda [&x](int a) -> int { return a + x; } va avea un constructor cu un parametru cu tipul din captura (capture). Ca orice metodă non-statică, operatorul apel va avea primul parametru ascuns care reprezintă referința la this.

Mai departe definim conceptul de FmapRequirement unde testăm dacă pentru un tip de intrare dat, un tip de funcție poate fi apelat cu acesta cu HasArgNoCVRefType și bineînțeles testând înainte că tipul poate fi un tip apelabil. Acest concept va fi folosit pentru implementări de funtori, nu pentru definiția conceptului de functor.

namespace sayten
{
template<typename InputT, typename FunctionT>
concept FmapRequirement = FmapParameter<FunctionT> &&
HasArgNoCVRefType<FunctionT, InputT, 0>;
}
namespace sayten
{
template<template<typename> typename FunctorT>
concept HasFmap =
requires (FunctorT<placeholder<1>> functor)
{
{ functor.fmap(placeholder_function<0, 1>) } -> std::convertible_to<FunctorT<placeholder<0>>>;
{ functor.fmap(placeholder_callable<0, 1>{}) } -> std::convertible_to<FunctorT<placeholder<0>>>;
};
}

Conceptul Functor este definit nu pe un tip, ci pe un template. Motivul este că functorul trebuie, pe de o parte, să mapeze tipurile de date pe instanțele de template, lucru care se întâmplă automat, dar pe de altă parte, să mapeze toate funcțiile între acestea.

Astfel, conceptul Functor este sinonim cu HasFmap în care se testează pe tipuri placeholder dacă metoda fmap respectă fără asumpții suplimentare aceeași semnătură ca în Haskell, lucru care ne asigură că metoda fmap va acționa pentru toate instanțele sale. Practic, încercăm să avem un echivalent pentru cuvântul cheie forall din Haskell.

Pentru cazul de față punem condiția ca pentru un apel al metodei fmap, fie pe o funcție, fie pe un obiect care poate fi apelat, să ne producă o instanță a functorului dat corespunzătoare sau măcar convertibilă la aceasta. Motivul pentru care am lăsat condiția cu std::convertible_to și nu cu std::same_as este că există situații în care se returnează tipuri anonime cum sunt funcțiile lambda care pot fi convertite la std::function.

namespace sayten
{
template<template<typename> typename FunctorT>
concept HasFmap =
requires (FunctorT<placeholder<1>> functor)
{
{ functor.fmap(placeholder_function<0, 1>) } -> std::convertible_to<FunctorT<placeholder<0>>>;
{ functor.fmap(placeholder_callable<0, 1>{}) } -> std::convertible_to<FunctorT<placeholder<0>>>;
};

template<template<typename> typename FunctorT>
concept Functor = HasFmap<FunctorT>;
}

Ca să elucidăm și cum arată tipurile noastre placeholder, acestea doar sunt niște structuri fără conținut care doar să le folosim pentru a testa cerințele din concepte.

namespace sayten
{
template<size_t>
struct placeholder
{
std::strong_ordering operator<=>(const placeholder&) const
{
return std::strong_ordering::equal;
}

bool operator==(const placeholder&) const
{
return true;
}
};

template<size_t result, size_t ...args>
struct placeholder_callable
{
std::strong_ordering operator<=>(const placeholder_callable&) const
{
return std::strong_ordering::equal;
}

bool operator==(const placeholder_callable&) const
{
return true;
}

placeholder<result> operator()(const placeholder<args>&...) const
{
return placeholder<result>{};
}
};

template<size_t result, size_t ...args>
placeholder<result> placeholder_function(const placeholder<args>&...)
{
return placeholder<result>{};
}
}

Pentru că am definit conceptul Functor, putem să-l testăm pe un functor clasic, pe functorul de identitate descris aici:

namespace sayten
{
template<typename ValueT>
class identity
{
private:
ValueT value;

public:
template<typename OtherValueT>
friend class identity;

identity() = delete;
constexpr identity(const ValueT &value) : value(value) {}
constexpr identity &operator=(const identity &) = default;

constexpr identity(const identity &other) : value(other.value) {}

template<typename MapFunctionT>
requires FmapRequirement<ValueT, MapFunctionT>
constexpr identity<typename unwrap_function<MapFunctionT>::result_type> fmap(const MapFunctionT &map_function) const
{
return identity<typename unwrap_function<MapFunctionT>::result_type>(map_function(value));
}
};
}

Nu am definit nimic în plus față de cerințele de bază pentru fmap, nici măcar nu am folosit moștenire sau implementarea unei interfețe cum în alte limbaje se practică pentru a obține o aproximare a unui functor. Cel mult am mai folosit reflecție statică pentru a pune semnătura corespunzătoare pentru fmap.

Astfel, dacă vom testa conceptul la compile time:

int main()
{
if constexpr (Functor<identity>)
{
std:cout << "Template identity is a functor\r\n";
}
else
{
std:cout << "Template identity is not a functor\r\n";
}

return 0;
}

Vom obține după codul echivalent:

int main()
{
std:cout << "Template identity is a functor\r\n";

return 0;
}