Les lambdas & opérateurs

Le C++ permet de redéfinir des opérateurs comme n'importe quelle fonction : il suffit de respecter le nom de l'opérateur et d'avoir une signature qui lui correspond.

struct Pair {
    int data[2];
};

Pair operator+(Pair const& lhs, Pair const& rhs) {
    return { lhs.data[0] + rs.data[0],
             lhs.data[1] + rs.data[1] };
};

De nombreuses interfaces implicites utilisées par les fonctions template se basent sur ces opérateurs. par exemple, les itérateurs utilisent au minimum les opérateurs suivants :

  • Déréférencer l'itérateur avec T& operator*().

  • Accéder aux membres de la valeur de l'itérateur avec T* operator->().

  • Incrémenter l'itérateur avec T& operator++().

Function objects & std::function

Les function objects sont les objets pouvant être appelés comme des fonctions. Il s'agit de pointeurs de fonction ou d'objets implémentant l'opérateur (). L'opérateur peut être défini plusieurs fois tant que la résolution des appels n'est pas ambigu.

// Fonction.
int f(float value) {
    return value - 1;
} 
// Function object.
struct Fun {
    int operator()(float value) {
        return value + 1;
    }
};

// Accepte n'importe quel function object.
// Les arguments et valeurs de retour peuvent être cast implicitement.
template <class F>
int call_with(F&& f, float value) {
    return f(value);
}

int main() {
    // Pointeur de fonction.
    auto ptr = f;
    int i = call_with(ptr, 2.f);
    int j = call_with(Fun{}, 3.14f);
}

Parfois, ces objets sont appelés functors en C++, ce qui n'a rien à voir avec les functors que l'on retrouve en programmation fonctionnelle.

std::function<Ret(Args...)> permet de stocker n'importe quel function object pouvant être copié, et dont les arguments et la valeur de retour correspondent avec la signature de la classe :

// Nos function objects.
struct FortyTwo {
    int operator()() const {
        return 42;
    }
}
struct Counter {
    int value;
    int operator()() {
        return ++value;
    }
};

int main() {
    auto fortytwo = FortyTwo{};
    auto producer = std::function<int()>{ fortytwo  };
    auto counter = Counter{ producer() };
    producer = counter;
    producer();
    producer();
    // Retourne 44.
    return producer();
}

L'appel à std::function est cependant moins performant qu'un appel direct au function object, car il nécessite une indirection qui empêche souvent l'inline, et déclenche une allocation dynamique selon la taille du function object.

Le concept de Callable permet à (entre autres) std::function ou std::thread d'utiliser l'objet comme une fonction : cela comprend les function objects ainsi que quelques cas supplémentaires, comme les pointeurs de fonction membres. Les Callables sont utilisés avec std:invoke(f, args...).

Lambdas

Parfois appelées closures, les lambdas sont des function objects créées automatiquement par le compilateur. Elles peuvent stocker des variables ou des références, qui sont spécifiées avec la capture donnée par les crochets :

int i = 1;
int j = 2;

// Le type de la lambda ne peut pas être connu, puiqu'il est créé
// à la compilation. Celle-ci capture i par référence et j par valeur.
auto lambda = [&i, j] (int mul) {
    i += j * mul;
};

// Code équivalent. Par défaut, la lambda est const (elle peut être
// marquée mutable pour enlever le const).
struct lambda_t {
    int& i;
    int j;
    void operator()(float mul) const {
        i += j * mul;
    }
};
auto lambda2 = lambda_t{ i, j };

Les captures peuvent également créer de nouvelles variables, et spécifier un mode de capture par défaut : dans ce cas, toutes les variables locales utilisées dans la capture seront capturées par valeur ou par référence. Attention cependant à ne pas avoir de dangling references (références désignant un objet n'existant plus) si la lambda vit plus longtemps que les objets capturés par références.

int a = 3;
int b = 2;

// Capture a et b par valeur : ils sont copiés dans la lambda.
// Puisqu'elle est marquée mutable, ces copies peuvent être modifiées.
// Le type de retour peut être spécifié manuellement.
auto counter = [=] () mutable -> int {
    a += b;
    return a;
};
counter(); // 5
counter(); // 7

auto ptr = std::make_unique<int>(42);

// On ne peut pas capturer ptr par valeur (std::unique_ptr ne peut pas être copié).
// On créé donc la variable  'val' dans laquelle 'ptr' est move.
// Cette lambda ne peut pas être copié, seulement move.
auto counter2 = [val = std::move(ptr)] {
    return (*ptr)++;
};
counter2(); // 42
counter2(); // 43

Si la lambda n'a pas de capture, elle peut être implicitement cast en pointeur de fonction. Elle peut également prendre des arguments template si ses arguments sont de type auto, et être constexpr.

int sum(int a, int b) {
    return a + b;
};

int main() {
    auto folder = sum;
    // La lambda est convertie en pointeur de type int (*) (int, int).
    folder = [] (int a, int b) {
        return a - b;
    };
    
    auto printer = [] (auto const& value) {
        std::cout << value << '\n';
    };
    printer(42);
    printer("hello");
}

Quelques opérateurs

Voici une liste des opérateurs disponibles, dont ceux qui peuvent être redéfinis.

Lorsque des objets sont créées et détruits avec new et delete, les opérateurs operator::new et operator::delete sont appelés. Ces opérateurs ne s'occupent pas de construire ou détruire l'objet, mais uniquement d'allouer et de désallouer sa mémoire.

Ils peuvent également être définis pour les tableaux, avec operator::new[] et operator::delete[]. Ces opérateurs peut être définis de façon globale ou uniquement pour certaines classes :

void* operator new(size_t nb) {
    std::cout << "global operator new called\n";
    return std::malloc(nb);
}
void operator delete(void* ptr) noexcept {
    std::cout << "global operator new called\n";
    std::free(ptr);
}

class Obj {
    static void* operator new(size_t nb) {
        std::cout << "Obj operator new called\n";
        return ::operator new(nb);
    }
};

Les opérateurs de comparaison (==, !=, >, <=, ...) peuvent être redéfinis. L'opérateur < est utilisé par std::less pour comparer deux objets : il s'agit d'un function object, utilisé par défaut par std::mappour trier ses clés.

struct Key { int value; };

bool operator<(Key const& lhs, Key const& rhs) {
    return lhs.value < rhs.value;
}

// Signature complète : std::map<
//     Key,
//     std::string,
//     std::less<Key>,
//     std::allocator<std::pair<Key const, std::string>>
std::map<Key, std::string> map {
    { {44}, "world" },
    { {42}, "hello" }
};

Les opérateurs arithmétiques (*, /, -=, ++, ...) et les opérations sur les bits (<<, &, |=, ~, ...) peuvent être redéfinies. << et >> sont également utilisés comme opérateurs de flux (pour envoyer ou recevoir des données), comme avec std::iostream.

Les opérateurs litéraux peuvent s'appliquer sur des chaînes de caractères ou des nombres litéraux. Ils sont souvent utilisés pour avoir une meilleure syntaxe. Les noms des opérateurs litéraux définis doivent commencer avec un underscore (les autres noms sont réservés).

struct kilometers { double meters; };

kilometers operator "" _km(unsigned long long nb) {
    return kilometers { nb * 3'600.0 };
}

int main() {
    kilometers kms = 10_km;
    
    using namespace std::literals;
    
    // Creates a std::string.
    auto string = "Hello world !"s;
    // Type : std::chrono::hours.
    auto hours = 24h;
    // Type : std::chrono::milliseconds.
    auto milliseconds = 423ms;
}

Pour aller plus loin

L'opérateur & peut être redéfini pour changer la valeur obtenue lorsque l'on prend l'adresse d'un objet. std::addressof permet de récupérer l'adresse de l'objet dans tous les cas.

L'opérateur new peut accepter des paramètres supplémentaires, qui seront passés avec la syntaxe d'un placement new.

Boost a une librairie header-only récente, CallableTraits, qui permet de détecter les arguments et la valeur de retour de function objects.

Challenge

(**) Créer une fonction qui prend un nombre arbitraire de function objects en paramètres, et renvoie un function object qui met à disposition tous les operateurs () des paramètres. Exemple :

auto os = std::ofstream{ "logs.txt" };
auto elements = std::tuple{ 42, "hello" };

auto f = make_overload(
    [&os] (int i)           { os << "int = " << i; },
    [&os] (char const* str) { os << "str = " << str; }
);
f(std::get<0>(elements)); // int = 42
f(std::get<1>(elements)); // str = hello

Last updated