Compartir a través de


Generadores de código fuente de expresiones regulares de .NET

Una expresión regular, o regex, es una cadena que permite a un desarrollador expresar un patrón que se busca, lo que hace que sea una forma común de buscar texto y extraer resultados como un subconjunto de la cadena buscada. En .NET, el espacio de nombres System.Text.RegularExpressions se usa para definir instancias de Regex y métodos estáticos, y buscar coincidencias con los patrones definidos por el usuario. En este artículo, aprenderá a usar la generación de origen para generar instancias de Regex para optimizar el rendimiento.

Nota:

Siempre que sea posible, use expresiones regulares generadas por código fuente en lugar de compilar expresiones regulares mediante la opción RegexOptions.Compiled. La generación de código fuente puede ayudar a que la aplicación se inicie y se ejecute más rápidamente, y se pueda reducir más. Para obtener información sobre cuándo es posible la generación de código fuente, vea Cuándo usarlo.

Expresiones regulares compiladas

Al escribir new Regex("somepattern"), suceden algunas cosas. El patrón especificado se analiza, tanto para garantizar la validez del patrón como para transformarlo en un árbol interno que representa la expresión regular analizada. A continuación, el árbol se optimiza de varias maneras para transformar el patrón en una variación funcionalmente equivalente que se puede ejecutar de forma más eficaz. El árbol se escribe en un formato que se puede interpretar como una serie de códigos de operación y operandos que proporcionan instrucciones al motor del intérprete de expresiones regulares sobre cómo realizar las coincidencias. Cuando se realiza una coincidencia, el intérprete simplemente recorre esas instrucciones y las procesa con respecto al texto de entrada. Al crear instancias de una nueva instancia de Regex o llamar a uno de los métodos estáticos en Regex, el intérprete es el motor predeterminado empleado.

Al especificar RegexOptions.Compiled, se realiza todo el mismo trabajo en tiempo de construcción. Las instrucciones resultantes se transforman aún más mediante el compilador basado en la emisión de reflexión en instrucciones de lenguaje intermedio que se escriben en unos cuantos valores DynamicMethod. Cuando se realiza una coincidencia, se invocan esos métodos DynamicMethod. Este lenguaje intermedio hace básicamente lo que haría el intérprete, excepto que está especializado para el patrón exacto que se procesa. Por ejemplo, si el patrón contiene [ac], el intérprete vería un código de operación que indica "coincide con el carácter de entrada en la posición actual con el conjunto especificado en esta descripción del conjunto". Mientras que el lenguaje intermedio compilado contendrá código que dice de forma eficaz, "coincide con el carácter de entrada en la posición actual en 'a' o 'c'". Este caso especial y la capacidad de realizar optimizaciones basadas en el conocimiento del patrón son algunas de las principales razones por las que especificar RegexOptions.Compiled produce un rendimiento de coincidencia mucho más rápido que el intérprete.

Hay varias desventajas asociadas con RegexOptions.Compiled. Lo más impactante es que su construcción es costosa. No solo se pagan los mismos costos que para el intérprete, sino que, luego, se debe compilar ese árbol RegexNode resultante y los códigos de operación y operandos generados en el lenguaje intermedio, lo que suma gastos que no se pueden menospreciar. El lenguaje intermedio generado debe compilarse posteriormente con JIT al usarse por primera vez, lo que da lugar a un gasto aún mayor al principio. RegexOptions.Compiled representa un equilibrio fundamental entre las sobrecargas en el primer uso y las sobrecargas en cada uso posterior. El uso de System.Reflection.Emit también impide el uso de RegexOptions.Compiled en determinados entornos; algunos sistemas operativos no permiten ejecutar el código generado dinámicamente y, en estos sistemas, Compiled no será una operación efectiva.

Generación de origen

.NET 7 introdujo un nuevo generador de origen RegexGenerator. Un generador de código fuente es un componente que se conecta al compilador y aumenta la unidad de compilación con código fuente adicional. El SDK de .NET (versión 7 y posteriores) incluye un generador de código fuente que reconoce el atributo GeneratedRegexAttribute en un método parcial que devuelve Regex. El generador de código fuente proporciona una implementación de ese método que contiene toda la lógica de Regex. Por ejemplo, es posible que ya haya escrito código como este:

private static readonly Regex s_abcOrDefGeneratedRegex =
    new(pattern: "abc|def",
        options: RegexOptions.Compiled | RegexOptions.IgnoreCase);

private static void EvaluateText(string text)
{
    if (s_abcOrDefGeneratedRegex.IsMatch(text))
    {
        // Take action with matching text
    }
}

Para usar el generador de código fuente, vuelva a escribir el código anterior de la siguiente manera:

[GeneratedRegex("abc|def", RegexOptions.IgnoreCase, "en-US")]
private static partial Regex AbcOrDefGeneratedRegex();

private static void EvaluateText(string text)
{
    if (AbcOrDefGeneratedRegex().IsMatch(text))
    {
        // Take action with matching text
    }
}

Sugerencia

El generador de código fuente omite la marca RegexOptions.Compiled, por lo que ya no es necesaria en la versión generada de origen.

La implementación generada de AbcOrDefGeneratedRegex() almacena en caché de forma similar una instancia singleton Regex, por lo que no se necesita almacenamiento en caché adicional para consumir código.

La siguiente imagen es una captura de pantalla de la instancia almacenada en caché generada por el origen, internal a la subclase Regex que emite el generador de origen:

Campo estático regex almacenado en caché

Pero, como se puede ver, no solo está haciendo new Regex(...). En su lugar, el generador de código fuente emite en código de C# una implementación derivada de Regex personalizada con una lógica similar a la que RegexOptions.Compiled emite en IL. Se obtienen todas las ventajas de rendimiento de RegexOptions.Compiled (más, de hecho) y las ventajas de inicio de Regex.CompileToAssembly, pero sin la complejidad de CompileToAssembly. El código fuente que se emite forma parte del proyecto, lo que significa que también es fácil de ver y depurar.

Depuración mediante código Regex generado por el código fuente

Sugerencia

En Visual Studio, haga clic con el botón derecho en la declaración del método parcial y seleccione Ir a definición. También puede seleccionar el nodo del proyecto en Explorador de soluciones y, luego, expandir Dependencies>Analizadores>System.Text.RegularExpressions.Generator>System.Text.RegularExpressions.Generator.RegexGenerator>RegexGenerator.g.cs para ver el código de C# generado desde este generador regex.

Puede establecer puntos de interrupción en él, puede recorrerlo paso a paso y puede usarlo como una herramienta de aprendizaje para comprender exactamente cómo el motor de expresiones regulares está procesando el patrón con la entrada. El generador incluso genera comentarios de barra diagonal triple (XML) para ayudar a comprender la expresión de un vistazo y dónde se usa.

Comentarios XML generados que describen regex

Dentro de los archivos generados por el código fuente

Con .NET 7, tanto el generador de código fuente como RegexCompiler se reescribieron casi por completo, lo que cambió fundamentalmente la estructura del código generado. Este enfoque se ha ampliado para controlar todas las construcciones (con una advertencia), y tanto RegexCompiler como el generador de código fuente continúan asignándose principalmente 1:1, siguiendo el nuevo enfoque. Considere la salida del generador de código fuente para una de las funciones principales de la expresión abc|def:

private bool TryMatchAtCurrentPosition(ReadOnlySpan<char> inputSpan)
{
    int pos = base.runtextpos;
    int matchStart = pos;
    ReadOnlySpan<char> slice = inputSpan.Slice(pos);

    // Match with 2 alternative expressions, atomically.
    {
        if (slice.IsEmpty)
        {
            return false; // The input didn't match.
        }

        switch (slice[0])
        {
            case 'A' or 'a':
                if ((uint)slice.Length < 3 ||
                    !slice.Slice(1).StartsWith("bc", StringComparison.OrdinalIgnoreCase)) // Match the string "bc" (ordinal case-insensitive)
                {
                    return false; // The input didn't match.
                }

                pos += 3;
                slice = inputSpan.Slice(pos);
                break;

            case 'D' or 'd':
                if ((uint)slice.Length < 3 ||
                    !slice.Slice(1).StartsWith("ef", StringComparison.OrdinalIgnoreCase)) // Match the string "ef" (ordinal case-insensitive)
                {
                    return false; // The input didn't match.
                }

                pos += 3;
                slice = inputSpan.Slice(pos);
                break;

            default:
                return false; // The input didn't match.
        }
    }

    // The input matched.
    base.runtextpos = pos;
    base.Capture(0, matchStart, pos);
    return true;
}

El objetivo del código generado por el código fuente es ser comprensible, con una estructura fácil de seguir, con comentarios que expliquen lo que se hace en cada paso y, en general, con código emitido bajo el principio rector de que el generador debe emitir código como si lo hubiera escrito un humano. Incluso cuando la vuelta atrás (backtracking) está implicada, la estructura de esta característica se convierte en parte de la estructura del código, en lugar de depender de una pila para indicar dónde saltar a continuación. Por ejemplo, este es el código de la misma función de coincidencia generada cuando la expresión es [ab]*[bc]:

private bool TryMatchAtCurrentPosition(ReadOnlySpan<char> inputSpan)
{
    int pos = base.runtextpos;
    int matchStart = pos;
    int charloop_starting_pos = 0, charloop_ending_pos = 0;
    ReadOnlySpan<char> slice = inputSpan.Slice(pos);

    // Match a character in the set [ABab] greedily any number of times.
    //{
        charloop_starting_pos = pos;

        int iteration = slice.IndexOfAnyExcept(Utilities.s_ascii_600000006000000);
        if (iteration < 0)
        {
            iteration = slice.Length;
        }

        slice = slice.Slice(iteration);
        pos += iteration;

        charloop_ending_pos = pos;
        goto CharLoopEnd;

        CharLoopBacktrack:

        if (Utilities.s_hasTimeout)
        {
            base.CheckTimeout();
        }

        if (charloop_starting_pos >= charloop_ending_pos ||
            (charloop_ending_pos = inputSpan.Slice(charloop_starting_pos, charloop_ending_pos - charloop_starting_pos).LastIndexOfAny(Utilities.s_ascii_C0000000C000000)) < 0)
        {
            return false; // The input didn't match.
        }
        charloop_ending_pos += charloop_starting_pos;
        pos = charloop_ending_pos;
        slice = inputSpan.Slice(pos);

        CharLoopEnd:
    //}

    // Advance the next matching position.
    if (base.runtextpos < pos)
    {
        base.runtextpos = pos;
    }

    // Match a character in the set [BCbc].
    if (slice.IsEmpty || ((uint)((slice[0] | 0x20) - 'b') > (uint)('c' - 'b')))
    {
        goto CharLoopBacktrack;
    }

    // The input matched.
    pos++;
    base.runtextpos = pos;
    base.Capture(0, matchStart, pos);
    return true;
}

Puede ver la estructura de la vuelta atrás (backtracking) en el código, con una etiqueta CharLoopBacktrack emitida para indicar dónde volver atrás y un objeto goto usado para saltar a esa ubicación cuando se produce un error en una parte posterior de la expresión regular.

Si observa el código que implementa RegexCompiler y el generador de código fuente, su aspecto será muy similar: métodos con nombre similar, estructura de llamadas similar e incluso comentarios similares a lo largo de la implementación. En su mayor parte, dan como resultado código idéntico, aunque uno en IL y el otro en C#. Por supuesto, el compilador de C# es responsable de convertir C# en IL, por lo que es probable que el IL resultante en ambos casos no sea idéntico. El generador de código fuente depende de ello en varios casos, y aprovecha el hecho de que el compilador de C# optimizará aún más varias construcciones de C#. Por lo tanto, el generador de código fuente producirá un código coincidente más optimizado que RegexCompiler. Por ejemplo, en uno de los ejemplos anteriores, puede ver que el generador de código fuente emite una sentencia switch, con una rama para 'a' y otra rama para 'b'. Debido a que el compilador de C# es muy bueno optimizando instrucciones switch, con múltiples estrategias a su disposición para hacerlo de manera eficiente, el generador de código fuente tiene una optimización especial que RegexCompiler no tiene. En el caso de las alternancias, el generador de código fuente examina todas las ramas y, si puede demostrar que cada rama comienza con un carácter inicial diferente, emitirá una instrucción switch sobre ese primer carácter y evitará generar cualquier código de vuelta atrás (backtracking) para esa alternancia.

Este es un ejemplo ligeramente más complicado de eso. Las alternancias se analizan más a fondo para determinar si es posible refactorizarlas de forma que los motores de vuelta atrás (backtracking) las optimicen más fácilmente y se genere un código fuente más sencillo. Una de estas optimizaciones permite extraer prefijos comunes de las ramas y, si la alternancia es atómica de modo que el orden no importa, reordenar las ramas para permitir más extracciones de este tipo. Puede ver el efecto de esto en el siguiente patrón de día de la semana Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday, que produce una función de coincidencia como esta:

private bool TryMatchAtCurrentPosition(ReadOnlySpan<char> inputSpan)
{
    int pos = base.runtextpos;
    int matchStart = pos;
    char ch;
    ReadOnlySpan<char> slice = inputSpan.Slice(pos);

    // Match with 6 alternative expressions, atomically.
    {
        int alternation_starting_pos = pos;

        // Branch 0
        {
            if ((uint)slice.Length < 6 ||
                !slice.StartsWith("monday", StringComparison.OrdinalIgnoreCase)) // Match the string "monday" (ordinal case-insensitive)
            {
                goto AlternationBranch;
            }

            pos += 6;
            slice = inputSpan.Slice(pos);
            goto AlternationMatch;

            AlternationBranch:
            pos = alternation_starting_pos;
            slice = inputSpan.Slice(pos);
        }

        // Branch 1
        {
            if ((uint)slice.Length < 7 ||
                !slice.StartsWith("tuesday", StringComparison.OrdinalIgnoreCase)) // Match the string "tuesday" (ordinal case-insensitive)
            {
                goto AlternationBranch1;
            }

            pos += 7;
            slice = inputSpan.Slice(pos);
            goto AlternationMatch;

            AlternationBranch1:
            pos = alternation_starting_pos;
            slice = inputSpan.Slice(pos);
        }

        // Branch 2
        {
            if ((uint)slice.Length < 9 ||
                !slice.StartsWith("wednesday", StringComparison.OrdinalIgnoreCase)) // Match the string "wednesday" (ordinal case-insensitive)
            {
                goto AlternationBranch2;
            }

            pos += 9;
            slice = inputSpan.Slice(pos);
            goto AlternationMatch;

            AlternationBranch2:
            pos = alternation_starting_pos;
            slice = inputSpan.Slice(pos);
        }

        // Branch 3
        {
            if ((uint)slice.Length < 8 ||
                !slice.StartsWith("thursday", StringComparison.OrdinalIgnoreCase)) // Match the string "thursday" (ordinal case-insensitive)
            {
                goto AlternationBranch3;
            }

            pos += 8;
            slice = inputSpan.Slice(pos);
            goto AlternationMatch;

            AlternationBranch3:
            pos = alternation_starting_pos;
            slice = inputSpan.Slice(pos);
        }

        // Branch 4
        {
            if ((uint)slice.Length < 6 ||
                !slice.StartsWith("fr", StringComparison.OrdinalIgnoreCase) || // Match the string "fr" (ordinal case-insensitive)
                ((((ch = slice[2]) | 0x20) != 'i') & (ch != 'İ')) || // Match a character in the set [Ii\u0130].
                !slice.Slice(3).StartsWith("day", StringComparison.OrdinalIgnoreCase)) // Match the string "day" (ordinal case-insensitive)
            {
                goto AlternationBranch4;
            }

            pos += 6;
            slice = inputSpan.Slice(pos);
            goto AlternationMatch;

            AlternationBranch4:
            pos = alternation_starting_pos;
            slice = inputSpan.Slice(pos);
        }

        // Branch 5
        {
            // Match a character in the set [Ss].
            if (slice.IsEmpty || ((slice[0] | 0x20) != 's'))
            {
                return false; // The input didn't match.
            }

            // Match with 2 alternative expressions, atomically.
            {
                if ((uint)slice.Length < 2)
                {
                    return false; // The input didn't match.
                }

                switch (slice[1])
                {
                    case 'A' or 'a':
                        if ((uint)slice.Length < 8 ||
                            !slice.Slice(2).StartsWith("turday", StringComparison.OrdinalIgnoreCase)) // Match the string "turday" (ordinal case-insensitive)
                        {
                            return false; // The input didn't match.
                        }

                        pos += 8;
                        slice = inputSpan.Slice(pos);
                        break;

                    case 'U' or 'u':
                        if ((uint)slice.Length < 6 ||
                            !slice.Slice(2).StartsWith("nday", StringComparison.OrdinalIgnoreCase)) // Match the string "nday" (ordinal case-insensitive)
                        {
                            return false; // The input didn't match.
                        }

                        pos += 6;
                        slice = inputSpan.Slice(pos);
                        break;

                    default:
                        return false; // The input didn't match.
                }
            }

        }

        AlternationMatch:;
    }

    // The input matched.
    base.runtextpos = pos;
    base.Capture(0, matchStart, pos);
    return true;
}

Al mismo tiempo, el generador de código fuente tiene que hacer frente a otros problemas que simplemente no existen cuando la salida va directamente a IL. Si examina un par de ejemplos de código anteriores, puedes ver algunas llaves comentadas de forma un tanto extraña. No se trata de un error. El generador de código fuente está reconociendo que, si esas llaves no estuvieran comentadas, la estructura de vuelta atrás (backtracking) se basaría en saltar desde fuera del ámbito a una etiqueta definida dentro de ese ámbito; tal etiqueta no sería visible para tal goto y el código daría error al compilar. Así, el generador de código fuente debe evitar que haya un ámbito en el camino. En algunos casos, simplemente se comentará el ámbito como se ha hecho aquí. En otros casos en los que no sea posible, a veces se pueden evitar construcciones que requieren ámbitos (como un bloque if de varias sentencias) si hacerlo resulta problemático.

El generador de código fuente controla todo lo que RegexCompiler controla, con una excepción. Al igual que con el control de RegexOptions.IgnoreCase, las implementaciones ahora usan una tabla de mayúsculas y minúsculas para generar conjuntos en tiempo de construcción y la coincidencia de la referencia inversa de IgnoreCase necesita consultar esa tabla. Esa tabla es interna para System.Text.RegularExpressions.dll y, por ahora, al menos, el código externo a ese ensamblado (incluido el código emitido por el generador de código fuente) no tiene acceso a ella. Esto hace que el control de referencias inversas de IgnoreCase sea un desafío en el generador de código fuente y no se admiten. Esta es la única construcción no admitida por el generador de código fuente que es compatible con RegexCompiler. Si intenta usar un patrón que tiene una de estas (lo cual es poco frecuente), el generador de código fuente no emitirá una implementación personalizada y, en su lugar, revertirá al almacenamiento en caché de una instancia normal de Regex:

Regex no admitido que todavía se almacena en caché

Además, ni RegexCompiler ni el generador de código fuente admiten el nuevo valor RegexOptions.NonBacktracking. Si especifica RegexOptions.Compiled | RegexOptions.NonBacktracking, la marca Compiled se omitirá y, si especifica NonBacktracking en el generador de código fuente, se revertirá de forma similar al almacenamiento en caché de una instancia normal de Regex.

Cuándo se debe usar

La guía general es que si puede usar el generador de código fuente, hágalo. Si usa Regex hoy en C# con argumentos conocidos en tiempo de compilación y, especialmente si ya usa RegexOptions.Compiled (porque la expresión regular se ha identificado como una zona activa que se beneficiaría de un rendimiento más rápido), querrá usar el generador de código fuente. El generador de código fuente proporcionará a la expresión regular las siguientes ventajas:

  • Todas las ventajas de rendimiento de RegexOptions.Compiled.
  • Las ventajas iniciales de no tener que realizar todos los análisis y compilaciones de expresiones regulares en tiempo de ejecución.
  • La opción de usar la compilación anticipada con el código generado para la expresión regular.
  • Mejor capacidad de depuración y comprensión de la expresión regular.
  • La posibilidad de reducir el tamaño de su aplicación recortada mediante el recorte de grandes franjas de código asociado con RegexCompiler (y potencialmente incluso la propia emisión de reflexión).

Cuando se usa con una opción como RegexOptions.NonBacktracking para la que el generador de código fuente no puede generar una implementación personalizada, seguirá emitiendo el almacenamiento en caché y los comentarios XML que describen la implementación, lo que la hace valiosa. La principal desventaja del generador de código fuente es que emite código adicional en el ensamblado, por lo que existe la posibilidad de que aumente el tamaño. Cuantas más expresiones regulares haya en tu aplicación y cuanto más grandes sean, más código se emitirá para ellas. En algunas situaciones, al igual que RegexOptions.Compiled puede ser innecesario, también puede serlo el generador de código fuente. Por ejemplo, si tiene una expresión regular que solo es necesaria en raras ocasiones y para la que el rendimiento no importa, podría ser más beneficioso confiar solo en el intérprete para ese uso esporádico.

Importante

.NET 7 incluye un analizador que identifica el uso de Regex que se podría convertir en el generador de código fuente, y un solucionador que realiza la conversión automáticamente:

Analizador y solucionador regexGenerator

Consulte también