Nota
El acceso a esta página requiere autorización. Puede intentar iniciar sesión o cambiar directorios.
El acceso a esta página requiere autorización. Puede intentar cambiar los directorios.
El uso de expresiones regulares con C++ moderno
“C++ es un lenguaje para desarrollar y usar abstracciones elegantes y eficientes.”—Bjarne Stroustrup
Esta cita del creador de C++ realmente resume lo que tanto me gusta del lenguaje. Para desarrollar soluciones para mis problemas puedo combinar las características del lenguaje y los estilos de programación que considero más indicados en cada caso.
C++11 introdujo un largo listado de características que por sí mismas son bastante apasionantes; pero si solo vemos un conjunto de características aisladas, entonces nos estamos perdiendo algo importante. La combinación de estas características es lo que convierte a C++ en el portento que tantos desarrolladores hemos llegado a apreciar. Para ilustrar este punto, le mostraré cómo usar las expresiones regulares con C++ moderno. El estándar de C++11 introdujo una poderosa biblioteca de expresiones regulares, pero si la usamos en forma aislada, con un estilo de programación C++ tradicional, puede resultar un poco cansadora. Desgraciadamente, esta es la forma en que la mayoría de las bibliotecas de C++11 se suelen enseñar. Claro que este método tiene sus razones de ser. Si buscamos un ejemplo conciso de cómo usar alguna biblioteca nueva, resultaría bastante abrumador si tuviéramos que comprender de golpe un sinfín de características nuevas del lenguaje. Como sea, la combinación de las características del lenguaje C++ y las bibliotecas realmente convierte a C++ en un lenguaje de programación productivo.
Para poder centrar los ejemplos en C++ y no en las expresiones regulares, tendré que usar por fuerza patrones muy simplistas. Podría preguntarse por qué uso expresiones regulares para problemas tan triviales, pero así evitamos perdernos en la mecánica del procesamiento de las expresiones. Este es un ejemplo sencillo: quisiera hacer coincidir cadenas de nombres donde estos podrían tener el formato “Kenny Kerr” o “Kerr, Kenny”. Tengo que identificar el nombre de pila y el apellido y luego los imprimo de alguna manera coherente. Primero tenemos la cadena de interés:
char const s[] = "Kerr, Kenny";
Para simplificar las cosas, usaré cadenas de caracteres y evitaré el uso de la clase basic_string de la biblioteca estándar, excepto para ilustrar los resultados de ciertas coincidencias. No es que basic_string tenga nada malo, pero, en mi experiencia, la mayoría de las cosas que hago con las expresiones suele operar con archivos asignados en memoria. Lo único que conseguiría al copiar el contenido de estos archivos a objetos de cadena sería desacelerar mis aplicaciones. Las funciones para expresiones regulares de la biblioteca estándar son indiferentes a las secuencias de caracteres y las procesan sin problemas, sin importar cómo están administradas.
Lo siguiente que necesito es un objeto de coincidencia:
auto m = cmatch {};
Esta es realmente una colección de coincidencias. El objeto cmatch es una plantilla de clases match_results que se especializó para cadenas char. A estas alturas, la “colección” de coincidencias está vacía:
ASSERT(m.empty());
También necesitaré un par de cadenas para recibir los resultados:
string name, family;
Ahora puedo llamar la función regex_match:
if (regex_match(s, m, regex { R"((\w+) (\w+))" }))
{
}
Esta función trata de hacer coincidir el patrón con toda la secuencia de caracteres. Esto se contrapone a la función regex_search que no tiene ningún problema en buscar una coincidencia en cualquier punto de la cadena. Simplemente creo el objeto regex “insertado” para mayor brevedad, pero esto no es gratis. Si tuviéramos que hacer coincidir esta expresión repetidas veces, más nos valdría crear el objeto regex una sola vez y luego conservarlo durante toda la vida de la aplicación. El patrón anterior hace coincidir los nombres con el formato “Kenny Kerr”. Suponiendo que se encontró una coincidencia, puedo copiar simplemente las cadenas parciales:
name = m[1].str();
family = m[2].str();
El operador de subíndice devuelve el objeto sub_match especificado. Un índice cero representa la coincidencia en su totalidad, mientras que los índices subsiguientes indican un grupo identificado en la expresión regular. Ni el objeto match_results ni sub_match crearán o asignarán una cadena parcial. En vez de esto, delinean el rango de caracteres con un puntero o iterador al inicio y al final de la coincidencia o coincidencia parcial, lo que produce el rango semiabierto usual que se prefiere en la biblioteca estándar. En este caso, llamo explícitamente el método str en cada sub_match para crear una copia de cada coincidencia parcial en forma de objetos de cadena.
Con esto, me encargo del primer formato posible. Para el otro, necesito otra llamada a regex_match con el patrón alternativo (técnicamente podríamos hacer coincidir ambos formatos con una sola expresión, pero ese no es el punto aquí):
else if (regex_match(s, m, regex { R"((\w+), (\w+))" }))
{
name = m[2].str();
family = m[1].str();
}
Este patrón hace coincidir los nombres con el formato “Kerr, Kenny”. Observe que tuve que revertir los índices, ya que el primer grupo representado en esta expresión regular identifica el apellido y el segundo el nombre de pila. Esto es todo para la función regex_match. En la figura 1 se entrega el listado completo como referencia.
Figura 1 Ejemplo de referencia regex_match
char const s[] = "Kerr, Kenny";
auto m = cmatch {};
string name, family;
if (regex_match(s, m, regex { R"((\w+) (\w+))" }))
{
name = m[1].str();
family = m[2].str();
}
else if (regex_match(s, m, regex { R"((\w+), (\w+))" }))
{
name = m[2].str();
family = m[1].str();
}
else
{
printf("No match\n");
}
No sé cómo lo ve usted, pero a mí el código de la figura 1 me parece muy monótono. Si bien la biblioteca de expresiones regulares es poderosa y flexible, no es especialmente elegante. Tengo que saber acerca de los objetos match_results y sub_match. Tengo que recordar cómo está indizada esta “colección” y cómo extraer los resultados. Podría evitar la creación de las copias, pero esto rápidamente se vuelve pesado.
Hasta aquí, usé varias características nuevas del lenguaje C++ que usted podría o no haber encontrado en el pasado, pero nada debería resultarle demasiado sorprendente. Ahora quiero mostrarle cómo puede usar las plantillas variádicas para hacer más interesante el uso de las expresiones regulares. En vez de entrar de lleno en más características del lenguaje, comenzaré por mostrarle una sencilla abstracción para simplificar el procesamiento de texto, que conducirá a una solución práctica y elegante.
Primero, definiré un tipo simple para representar una secuencia de caracteres que no necesariamente termina en null. Esta es la clase strip:
struct strip
{
char const * first;
char const * last;
strip(char const * const begin,
char const * const end) :
first { begin },
last { end }
{}
strip() : strip { nullptr, nullptr } {}
};
Sin lugar a dudas que podría reutilizar numerosas clases como esta, pero encuentro que permite evitar demasiadas dependencias al producir abstracciones simples.
La clase strip no hace mucho, pero la aumentaré con un conjunto de funciones que no son miembros. Comenzaré con un par de funciones para definir el rango en forma genérica:
auto begin(strip const & s) -> char const *
{
return s.first;
}
auto end(strip const & s) -> char const *
{
return s.last;
}
Aunque no es estrictamente necesaria para este ejemplo, encuentro que esta técnica ofrece una medida útil de congruencia con los contenedores y algoritmos de la biblioteca estándar. Volveré a las funciones begin y end dentro de un instante. A continuación viene la función auxiliar make_strip:
template <unsigned Count>
auto make_strip(char const (&text)[Count]) -> strip
{
return strip { text, text + Count - 1 };
}
Esta función resulta práctica cuando tratamos de crear una instancia de strip a partir de una cadena literal. Por ejemplo, puedo inicializar un objeto strip del siguiente modo:
auto s = make_strip("Kerr, Kenny");
A continuación, frecuentemente resulta útil determinar el largo o tamaño del objeto strip:
auto size(strip const & s) -> unsigned
{
return end(s) - begin(s);
}
Aquí puede apreciar que simplemente reutilizo las funciones begin y end para evitar la dependencia de los miembros de strip. Podría proteger los miembros de la clase strip. Por otro lado, a menudo resulta útil poder manipularlos directamente desde dentro de un algoritmo. Pero aún así, no es necesario crear una dependencia dura, así que no lo haré.
Evidentemente, no basta simplemente con crear una cadena a partir de un objeto strip:
auto to_string(strip const & s) -> string
{
return string { begin(s), end(s) };
}
Esto puede resultar útil si alguno de los resultados dura más que las secuencias de caracteres originales. Así completamos las operaciones básicas de strip. Puedo inicializar un objeto strip y determinar su tamaño y, gracias a las funciones begin y end, puedo usar una instrucción range-for para iterar por los caracteres:
auto s = make_strip("Kenny Kerr");
for (auto c : s)
{
printf("%c\n", c);
}
Cuando escribí la clase strip por primera vez, esperaba que podría usar el nombre “begin” y “end” para sus miembros en vez de “first” y “last”. El problema es que el compilador, cuando se enfrenta a una instrucción range-for, primero trata de encontrar miembros adecuados que se podrían llamar como funciones. Si el rango o secuencia en cuestión no incluye ningún miembro llamado begin y end, entonces el compilador busca un par adecuado en el ámbito que lo contiene. El problema es que si el compilador encuentra miembros llamados begin y end pero estos no son adecuados, entonces no trata de seguir buscando. Podríamos pensar que este comportamiento es un poco corto de miras, pero C++ tiene reglas complejas para la búsqueda de nombres y cualquier desviación las volvería aún más confusa e incoherente.
La clase strip es una construcción sencilla, pero no hace gran cosa por su propia cuenta. La combinaré ahora con la biblioteca de expresiones regulares para producir una abstracción elegante. Quiero ocultar la mecánica del objeto match, la parte tediosa del procesamiento de las expresiones. Es aquí donde entran en juego las plantillas variádicas. La clave para entender las plantillas variádicas es entender que podemos separar el primer argumento del resto. Esto generalmente resulta en una recursión en tiempo de compilación. Puedo definir una plantilla variádica para desempaquetar un objeto match en los argumentos subsiguientes:
template <typename... Args>
auto unpack(cmatch const & m,
Args & ... args) -> void
{
unpack<sizeof...(Args)>(m, args...);
}
El argumento “typename...” indica que Args es un paquete de parámetros de plantilla. El “...” correspondiente en el tipo de args indica que args es un paquete de parámetros de función. La expresión “sizeof...” determina el número de elementos en el paquete de parámetros. El “...” final después de args le indica al compilador que expanda el paquete de parámetros a la secuencia de elementos correspondiente.
El tipo de cada argumento podría ser diferente, pero en este caso cada uno será una referencia no constante a strip. Empleo una plantilla variádica, para permitir un número desconocido de argumentos. Hasta aquí, la función unpack no pareciera ser recursiva. Reenvía sus argumentos a otra función unpack con un argumento de plantilla adicional:
template <unsigned Total, typename... Args>
auto unpack(cmatch const & m,
strip & s,
Args & ... args) -> void
{
auto const & v = m[Total - sizeof...(Args)];
s = { v.first, v.second };
unpack<Total>(m, args...);
}
Sin embargo, esta función unpack separa el primer argumento posterior al objeto de coincidencia del resto. Esto corresponde a una recursión en tiempo de compilación. Suponiendo que el paquete de parámetros args no esté vacío, se llama a sí misma con los argumentos restantes. En algún momento, la secuencia de argumentos queda vacía y se necesita una tercera función unpack para lidiar con este término:
template <unsigned>
auto unpack(cmatch const &) -> void {}
Esta función no hace nada. Simplemente sirve para reconocer el hecho que el paquete de parámetros puede estar vacío. Las funciones unpack anteriores contienen la esencia para desempaquetar el objeto de coincidencia. La primera función unpack capturó la cantidad original de elementos en el paquete de parámetros. Esto es necesario, ya que cada llamada recursiva producirá un paquete de parámetros nuevo con un tamaño decreciente. Observe cómo sustraigo el tamaño del paquete de parámetros del total original. Dado este tamaño total o estable, puedo indexar la colección de coincidencias para recuperar las coincidencias parciales y copiar sus límites respectivos a los argumentos variádicos.
Esto se encarga de desempaquetar el objeto de coincidencia. Aunque no es estrictamente necesario, me parece útil ocultar el objeto de coincidencia mismo si no se usa directamente: por ejemplo si solo se necesita para acceder al prefijo y sufijo de la coincidencia. Encapsularé todo el conjunto para proporcionar una abstracción más simple de la coincidencia:
template <typename... Args>
auto match(strip const & s,
regex const & r,
Args & ... args) -> bool
{
auto m = cmatch {};
if (regex_match(begin(s), end(s), m, r))
{
unpack<sizeof...(Args)>(m, args...);
}
return !m.empty();
}
Esta función también es una plantilla variádica, pero no es recursiva por su propia cuenta. Solamente redirige sus argumentos a la función unpack original para el procesamiento. También se encarga de proporcionar un objeto de coincidencia local y de definir la secuencia de búsqueda en términos de las funciones auxiliares begin y end de strip. Se puede escribir una función prácticamente idéntica para acomodar regex_search en vez de regex_match. Ahora puedo reescribir el ejemplo de la figura 1 en forma mucho más sencilla:
auto const s = make_strip("Kerr, Kenny");
strip name, family;
if (match(s, regex { R"((\w+) (\w+))" }, name, family) ||
match(s, regex { R"((\w+), (\w+))" }, family, name))
{
printf("Match!\n");
}
¿Qué pasa con la iteración? Las funciones unpack también resultan prácticas para procesar los resultados de la coincidencia de una búsqueda iterativa. Imagínese una cadena con el “Hello world” canónico en diferentes idiomas:
auto const s =
make_strip("Hello world/Hola mundo/Hallo wereld/Ciao mondo");
Puedo hacer coincidir cada uno con la siguiente expresión regular:
auto const r = regex { R"((\w+) (\w+))" };
La biblioteca de expresiones regulares proporciona el iterador regex_iterator para recorrer las coincidencias, pero el uso directo de los iteradores puede volverse tedioso. Una opción es escribir una función for_each que llame un predicado para cada coincidencia:
template <typename F>
auto for_each(strip const & s,
regex const & r,
F callback) -> void
{
for (auto i = cregex_iterator { begin(s), end(s), r };
i != cregex_iterator {};
++i)
{
callback(*i);
}
}
Podría llamar luego esta función con una expresión lambda para desempaquetar cada coincidencia:
for_each(s, r, [] (cmatch const & m)
{
strip hello, world;
unpack(m, hello, world);
});
Desde luego, esto funciona, pero siempre me parece frustrante que no exista alguna forma para salir fácilmente de este tipo de bucle. La instrucción range-for ofrece una alternativa más conveniente. Comenzaré por definir un rango iterador simple que el compilador reconocerá para implementar el bucle range-for:
template <typename T>
struct iterator_range
{
T first, last;
auto begin() const -> T { return first; }
auto end() const -> T { return last; }
};
Ahora puedo escribir una función for_each más simple que simplemente devuelve un iterator_range:
auto for_each(strip const & s,
regex const & r) -> iterator_range<cregex_iterator>
{
return
{
cregex_iterator { begin(s), end(s), r },
cregex_iterator {}
};
}
El compilador se encargará de producir la iteración y yo puedo escribir una instrucción range-for con un costo sintáctico mínimo, con la opción de realizar una interrupción temprana, si quiero:
for (auto const & m : for_each(s, r))
{
strip hello, world;
unpack(m, hello, world);
printf("'%.*s' '%.*s'\n",
size(hello), begin(hello),
size(world), begin(world));
}
La consola presenta los resultados esperados:
'Hello' 'world'
'Hola' 'mundo'
'Hallo' 'wereld'
'Ciao' 'mondo'
C++11 y las versiones posteriores ofrecen una oportunidad de revitalizar el desarrollo de software con C++ con un estilo de programación moderno que permite producir abstracciones elegantes y eficientes. La gramática de las expresiones regulares puede dar una lección de humildad incluso al desarrollador más avezado. ¿Por qué no dedicar algunos minutos a desarrollar una abstracción más elegante? ¡Al menos la porción de C++ de la tarea será todo un placer!
Kenny Kerr es programador de informática radicado en Canadá, autor para Pluralsight y MVP de Microsoft. Tiene un blog en kennykerr.ca y puede seguirlo en Twitter en twitter.com/kennykerr.