Pattern matching en C++

Introducción


Fuente: https://commons.wikimedia.org/wiki/File:Jigsaw.svg
Licencia: Public Domain
La técnica de programación conocida como coincidencia de patrones (pattern matching) --de uso común en lenguajes como C#, Haskell, Mathematica, Scala, Swift o Rust-- constituye una herramienta versátil con la que poder extraer información de una serie de tokens según su estructura o forma. En esta técnica, una expresión es comparada con una lista de patrones (patterns) con el fin de que, de producirse una coincidencia (match), pueda extraerse información útil de la misma. La semántica empleada es la de primera coincidencia (first match), y no la de mejor coincidencia (best match). El esquema general a seguir toma la forma:

       inspeccionar <expresión>:
          <comparar_patrón_1> <acción_a_realizar_1>
          <comparar_patrón_2> <acción_a_realizar_2>
          ...

C++ dispone en la actualidad de dos estructuras de selección:
  • La sentencia switch, que permite operar con un único valor convertible a entero y ejecutar una de varias acciones en función de éste (multibranch testing). Las restricciones de uso de esta estructura (incapaz, por ejemplo, de operar con strings) se encuentran motivadas por razones de eficiencia. La comprensión del código suele ser, en este caso, directa y sencilla.
  • La sentencia if-else, que puede operar sobre expresiones booleanas arbitrariamente complejas, las cuales no siempre involucran los mismos conjuntos de variables. La comprensión de esta estructura suele complicarse rápidamente al incrementarse el número de ramas y/o la complejidad de las expresiones booleanas.
Como veremos a continuación, la técnica de pattern matching tiende un puente entre ambas estructuras, proporcionando una herramienta de alta expresividad y fácil composición.


Biblioteca MPark.Patterns


La propuesta de estandarización P1371R1, liderada por desarrolladores de Bloomberg y Facebook, propone dar soporte a la técnica de pattern matching en C++23. Puedes consultarla en

      http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1371r1.pdf

así como en la conferencia:
  • CppCon 2019: Michael Park "Pattern Matching: A Sneak Peek" - https://youtu.be/PBZBG4nZXhk

En la actualidad, el esfuerzo de sus autores se centra en garantizar una integración natural y eficiente de esta herramienta dentro del lenguaje. A la espera de futuros avances en este campo y de su implementación en los principales compiladores, podemos hacer uso de la conocida biblioteca header-only MPark.Patterns, para la cual presentaremos algunos ejemplos de uso en este post. Puedes encontrar más información en su portal de GitHub

      https://github.com/mpark/patterns

así como en la conferencia:
  • CppCon 2017: Michael Park "MPark.Patterns: Pattern Matching in C++" - https://youtu.be/HaZ1UQXnuC8

Una vez configurada, podremos hacer uso de la biblioteca sin más que incluir el fichero de cabecera

   #include <mpark/patterns.hpp>

Con el fin de asemejar nuestro código a la propuesta P1371R1 y simplificar la sintaxis en la medida de lo posible, procederemos a renombrar dos funciones clave de la biblioteca, match y pattern, como inspect y pt, respectivamente:

template<typename... Values> auto inspect(Values&&... vs) noexcept {     return mpark::patterns::match(std::forward<Values>(vs)...); } template<typename... Patterns> auto pt(Patterns const&... ps) noexcept {     return mpark::patterns::pattern(ps...); }

En los siguientes ejemplos haremos uso de la función fmt::print() perteneciente a la biblioteca {fmt}lib, así como:

using mpark::patterns::anyof, mpark::patterns::arg, mpark::patterns::arg_index, mpark::patterns::as, mpark::patterns::ds, mpark::patterns::none, mpark::patterns::some, mpark::patterns::_;


Ejemplos de uso


Ejemplo 1

Nuestro primer ejemplo itera un vector de personajes de la obra de Dumas "El Conde de Montecristo" (salvo algún que otro intruso), mostrando por pantalla un amistoso saludo de bienvenida para los miembros de las familias Dantès y Morrel y un abrupto mensaje de desaprobación para los Mondego y Villefort:

   struct Character {       string first_name,              last_name;    };    auto characters = vector<Character>{       {"Edmond""Dantes"},       {"Fernand""Mondego"},       {"Julien""Morrel"},       {"Gerard""de Villefort"},       {"Maximilien""Morrel"},       {"Harry""Potter"},       {"Heloise""de Villefort"}    };    for (auto const& c : characters) {       auto& [nm, sr] = c; // (A) name and surname       inspect(sr)(        // (B)          pt(anyof("Dantes""Morrel"))        = [&]{ print("welcome {} {}\n", nm, sr); },          pt(anyof("Mondego""de Villefort")) = [&]{ print("shame on you {} {}!\n", nm, sr); },          pt(_)  = [&]{ print("{} {}? who are you?\n", nm, sr); }       );    }

Observemos en primer lugar la descomposición de los distintos objetos de tipo Character en parejas de nombres y apellidos gracias a las declaraciones structured binding ofrecidas por C++17 (A). El apellido de cada personaje es entonces inspeccionado en (B), de modo que pueda imprimirse el mensaje que corresponda a cada familia. El último patrón, con el identificador comodín/wildcard _, produce una coincidencia (match) para cualquier valor no contemplado en los casos que le preceden. Ello permite la emisión de un mensaje de extrañeza para personajes ajenos a la obra. El output generado será, así:

   welcome Edmond Dantes
   shame on you Fernand Mondego!
   welcome Julien Morrel
   shame on you Gerard de Villefort!
   welcome Maximilien Morrel
   Harry Potter? who are you?
   shame on you Heloise de Villefort!

Ejemplo 2

La biblioteca permite también trabajar de forma natural con enumeraciones:

   enum class Color { redyellowgreenblue };    auto const c = Color::green;    auto const [r, g, b] = inspect(c)(       pt(Color::red)    = []{ return array{1.00.00.0}; },       pt(Color::yellow) = []{ return array{1.01.00.0}; },       pt(Color::green)  = []{ return array{0.01.00.0}; },       pt(Color::blue)   = []{ return array{0.00.01.0}; }    );    print("Color: ({:.2f}, {:.2f}, {:.2f})\n", r, g, b); // Color: (0.0, 1.0, 0.0)

Ejemplo 3

El siguiente código solicita al usuario la introducción por la terminal de las coordenadas enteras de un punto en una cuadrícula 3-dimensional para, a continuación, clasificarlo según el esquema siguiente:
  • El punto coincide con el origen de coordenadas.
  • El punto está contenido en uno de los ejes coordenados.
  • El punto no está contenido en ningún eje coordenado, pero pertenece aún a algún plano coordenado.
  • El punto se encuentra fuera de los planos coordenados.

   auto [x, y, z] = array<int3>{}; // (A)    print("Insert x-y-z coordinates: ");    cin >> x >> y >> z;    auto const location = inspect(x, y, z)( // (B)       pt(0,0,0) = []{ return "origin of coordinates"; },       pt(_,0,0) = []{ return "x-axis"; },       pt(0,_,0) = []{ return "y-axis"; },       pt(0,0,_) = []{ return "z-axis"; },       pt(0,_,_) = []{ return "on yz-plane"; },       pt(_,0,_) = []{ return "on xz-plane"; },       pt(_,_,0) = []{ return "on xy-plane"; },       pt(_,_,_) = []{ return "outside the coordinate planes"; }    );    print("({}, {}, {}) -> {}\n", x, y, z, location); // (C)

Nuevamente, procedemos a la descomposición del array en tres coordenadas espaciales xyz (A) que poder inspeccionar en (B). Según el patrón exhibido por el punto, retornamos una cadena de caracteres diferente con la que inicializar la variable location, parte integrante del mensaje enviado por consola en (C).

Ejemplo 4

El siguiente código demuestra la integración de la biblioteca con el tipo suma std::variant. Se solicita al usuario la introducción por terminal de dos valores reales a dividir. La primera inspección (A) genera una variable de tipo IndeterminateInfinity o double según tratemos una indeterminación 0/0, un valor infinito num/0 (con num no nulo) o un cociente entre dos valores no nulos num/den, respectivamente. La segunda inspección (B) se encarga entonces de visitar al variant e imprimir un mensaje apropiado para cada caso:

   struct Indeterminate{};    struct Infinity{};    using Quotient = variant<double, Indeterminate, Infinity>;    auto const division = [](double num, double den) {       return inspect(num, den)( // (A)          pt(0,0) =  []{ return Quotient{Indeterminate{}}; },          pt(_,0) =  []{ return Quotient{Infinity{}}; },          pt(_,_) = [&]{ return Quotient{num/den}; }       );    };    print("Insert numerator and denominator: ");    auto num = 0.0, den = 0.0;    cin >> num >> den;    inspect(division(num, den))( // (B)       pt(as<double>(arg))      = [](auto res){ print("{:.2f}\n", res); },       pt(as<Infinity>(_))      = []{ print("infinity\n"); },       pt(as<Indeterminate>(_)) = []{ print("indeterminate\n"); }    );

En la última inspección, el patrón arg coincide con el valor del cociente y lo vincula pasándolo como argumento al controlador lambda correspondiente. Esto contrasta con el patrón comodín _, que coincide con cualquier valor y lo descarta sin pasarlo al controlador asociado. Tanto arg como el comodín _ pueden repetirse múltiples veces en un mismo patrón, al ser las coincidencias independientes para cada uno de ellos. Puedes revisar el ejemplo 3 a modo de comprobación, así como el siguiente ejemplo adicional con variant.

Ejemplo 5

using Pair = pair<doubledouble>;    auto var = variant<int, Pair, string>{};    var = Pair{7.8, -1.2};    inspect(var)(       pt(as<int>(arg))          = [](auto n)        { print("integer {}\n", n); },       pt(as<Pair>(ds(arg,arg))) = [](auto a, auto b){ print("pair {},{}\n", a, b); },       pt(as<string>(arg))       = [](auto const& s) { print("string '{}'\n", s); }    );

En el segundo patrón, la función ds de la biblioteca MPark.Patterns permite la descomposición in situ de la pareja de doubles. El output producido por el código anterior sería, en este caso, "pair 7.8, -1.2".

Llegados a este punto, podría resultar conveniente definir una serie de macros que simplificasen la declaración de los controladores lambda:

#define THEN = [&] #define THEN1(a) = [&](auto&& a) #define THEN2(a,b) = [&](auto&& a, auto&& b) // etc

La inspección del ejemplo anterior podría reescribirse entonces como:

inspect(var)(       pt(as<int>(arg))          THEN1(n)  { print("integer {}\n", n); },       pt(as<Pair>(ds(arg,arg))) THEN2(a,b){ print("pair {},{}\n", a, b); },       pt(as<string>(arg))       THEN1(s)  { print("string '{}'\n", s); }    );

Ejemplo 6

La biblioteca MPark.Patterns proporciona la macro IDENTIFIERS con la que introducir nuevos identificadores alternativos a arg, pudiendo dotarles de los nombres que consideremos más convenientes. La diferencia fundamental de estos nuevos identificadores frente a arg reside en que, de repetirse uno de ellos en un patrón, los valores ligados a dicho identificador deberán ser a su vez iguales (ver código más abajo). Siempre que un identificador venga precedido de un guión bajo, tendrá carácter descartante (discarding); en caso contrario, será vinculante (binding).

Así, a modo de ejemplo ilustrativo, podríamos definir la siguiente función para comprobar si dos enteros son idénticos:

   using mpark::patterns::arg_index;    auto const is_same = [](int a, int b){       IDENTIFIERS(x, y);       inspect(a, b)(          pt(x,x) = [](auto x){ print("same arguments {}\n", x); },          pt(x,y) = [](auto x, auto y){ print("{} not equal to {}\n", x, y); }       );    };    is_same(77); // output: same arguments 7    is_same(12); // output: 1 not equal to 2

Ejemplo 7

El siguiente código, inspirado en el ejemplo 8.5.1.4 de doc.rust-lang.org/rust-by-example, profundiza en la composición de patrones mediante el uso de la función ds:

   struct S {       pair<intint> pr{};       double db{};    };    auto const [p, d] = S{{12}, 9.2};    inspect(p, d)(       pt(ds(1,_),_) = [&]{ print("1st of p is 1, 2nd is {}, and d is {}\n", p.second, d); },       pt(_,2)       = [&]{ print("p({},{}) is anything and d is 2\n", p.first, p.second); },       pt(_,_)       =  []{ print("any other case\n"); }    ); // output: 1st of p is 1, 2nd is 2, and d is 9.2

Ejemplo 8

El siguiente código muestra un ejemplo de polimorfismo dinámico mediante herencia y la consecución de un dynamic dispatch mediante inspección de punteros:

   struct Dinosaur {   virtual auto level() const -> int = 0;   virtual ~Dinosaur() = default;  };    struct Brachio : Dinosaur {   auto level() const -> int override { return 5; }  };    struct Trex : Dinosaur {   auto level() const -> int override { return 20; }  };    auto p = unique_ptr<Dinosaur>{make_unique<Trex>()};    inspect(p)(       pt(some(as<Trex>(_))) = [&]{ print("Trex Final Boss - level {}\n", p->level()); },       pt(some(_))           = [&]{ print("any other dinosaur - level {}\n", p->level()); },       pt(none)              =  []{ print("null pointer\n"); }    ); // output: Trex Final Boss - level 20

Aquí, some comprueba que el puntero sea desreferenciable, mientras que none corresponde a un puntero nulo.

No hay comentarios:

Publicar un comentario