StringTokenizer para .NET Core
Dividir una cadena en tokens es un tema más complejo de lo que String.Split () quiere hacernos creer. Hay al menos tres políticas comunes según las cuales una cadena se puede interpretar y dividir en tokens.
Política 1: Equivalente a String.Split ()
No hay mucho que mencionar sobre esta política. Dada una cadena sy un delimitador d, divídalo sen segmentos delimitados por d. El principal inconveniente aquí es que si el delimitador es parte de al menos uno de los tokens, reconstruir los tokens deseados puede resultar costoso.
Política 2: escapar de los caracteres especiales
Un carácter se declara como carácter de escape e (comúnmente la barra invertida \), lo que hace que el carácter que le sigue pierda su significado especial. Entonces, una cadena de token podría verse así:
token_1 token_2 very\ long \ token
que sería equivalente a
{ "token_1", "token_2", "very long token" }
Política 3: coloque los tokens entre comillas
Este enfoque se utiliza, por ejemplo, en archivos CSV generados en MSExcel. Todo lo que esté entre comillas se considera un símbolo. Si las comillas "son parte del token, se duplican "". Entonces, una cadena de token podría verse así:
token_1,token_2,"token2,5"
que sería equivalente a
{ "token_1", "token_2", "token2,5" }
Código
using System;
using System.Text;
using System.Text.RegularExpressions;
using System.Collections.Generic;
namespace Pillepalle1.ConsoleTelegramBot.Model.Misc
{
public sealed class StringTokenizer
{
private string _sourceString = null; // Provided data to split
#region Constructors
/// <summary>
/// Creates a new StringTokenizer
/// </summary>
/// <param name="dataInput">Data to be split into tokens</param>
public StringTokenizer(string dataInput)
{
_sourceString = dataInput ?? string.Empty;
}
#endregion
#region Interface
/// <summary>
/// Access tokens by index
/// </summary>
public string this[int index]
{
get
{
if (index >= this.Count)
{
return String.Empty;
}
return _Tokens[index];
}
}
/// <summary>
/// How many tokens does the command consist of
/// </summary>
public int Count
{
get
{
return _Tokens.Count;
}
}
/// <summary>
/// Which strategy is used to split the string into tokens
/// </summary>
public StringTokenizerStrategy Strategy
{
get
{
return _strategy;
}
set
{
if (value != _strategy)
{
_strategy = value;
_tokens = null;
}
}
}
private StringTokenizerStrategy _strategy = StringTokenizerStrategy.Split;
/// <summary>
/// Character used to delimit tokens
/// </summary>
public char Delimiter
{
get
{
return _delimiter;
}
set
{
if (value != _delimiter)
{
_delimiter = value;
_tokens = null;
}
}
}
private char _delimiter = ' ';
/// <summary>
/// Character used to escape the following character
/// </summary>
public char Escape
{
get
{
return _escape;
}
set
{
if (value != _escape)
{
_escape = value;
if (Strategy == StringTokenizerStrategy.Escaping)
{
_tokens = null;
}
}
}
}
private char _escape = '\\';
/// <summary>
/// Character used to surround tokens
/// </summary>
public char Quotes
{
get
{
return _quotes;
}
set
{
if (value != _quotes)
{
_quotes = value;
if (Strategy == StringTokenizerStrategy.Quotation)
{
_tokens = null;
}
}
}
}
private char _quotes = '"';
#endregion
#region Predefined Regex
private Regex Whitespaces
{
get
{
return new Regex("\\s+");
}
}
#endregion
#region Implementation Details
/// <summary>
/// Formats and splits the tokens by delimiter allowing to add delimiters by quoting
/// </summary>
private List<string> _SplitRespectingQuotation()
{
string data = _sourceString;
// Doing some basic transformations
data = Whitespaces.Replace(data, " ");
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Initialisation
List<string> l = new List<string>();
char[] record = data.ToCharArray();
StringBuilder property = new StringBuilder();
char c;
bool quoting = false;
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Scan character by character
for (int i = 0; i < record.Length; i++)
{
c = record[i];
// Quotation-Character: Single -> Quote; Double -> Append
if (c == Quotes)
{
if (i == record.Length - 1)
{
quoting = !quoting;
}
else if (Quotes == record[1 + i])
{
property.Append(c);
i++;
}
else
{
quoting = !quoting;
}
}
// Delimiter: Escaping -> Append; Otherwise append
else if (c == Delimiter)
{
if (quoting)
{
property.Append(c);
}
else
{
l.Add(property.ToString());
property.Clear();
}
}
// Any other character: Append
else
{
property.Append(c);
}
}
l.Add(property.ToString()); // Add last token
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Checking consistency
if (quoting) throw new FormatException(); // All open quotation marks closed
return l;
}
/// <summary>
/// Splits the string by declaring one character as escape
/// </summary>
private List<string> _SplitRespectingEscapes()
{
string data = _sourceString;
// Doing some basic transformations
data = Whitespaces.Replace(data, " ");
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Initialisation
List<string> l = new List<string>();
char[] record = data.ToCharArray();
StringBuilder property = new StringBuilder();
char c;
bool escaping = false;
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Scan character by character
for (int i = 0; i < record.Length; i++)
{
c = record[i];
if (escaping)
{
property.Append(c);
escaping = false;
continue;
}
if (c == Escape)
{
escaping = true;
}
else if (c == Delimiter)
{
l.Add(property.ToString());
property.Clear();
}
else
{
property.Append(c);
}
}
return l;
}
/// <summary>
/// Splits the string by calling a simple String.Split
/// </summary>
private List<string> _SplitPlain()
{
return new List<string>(Whitespaces.Replace(_sourceString, " ").Split(Delimiter));
}
/// <summary>
/// Backer for tokens
/// </summary>
private List<string> _Tokens
{
get
{
if (null == _tokens)
{
switch (Strategy)
{
case (StringTokenizerStrategy.Quotation): _tokens = _SplitRespectingQuotation(); break;
case (StringTokenizerStrategy.Escaping): _tokens = _SplitRespectingEscapes(); break;
default: _tokens = _SplitPlain(); break;
}
}
return _tokens;
}
}
private List<string> _tokens = null;
#endregion
}
public enum StringTokenizerStrategy
{
Split,
Quotation,
Escaping
}
}
Respuestas
No estoy tan seguro de que esto pertenezca a una clase, ¡al menos ni una sola!
Dé un paso atrás y observe qué une y qué separa a cada "estrategia". Todos necesitan transformar una cadena de entrada en una lista de tokens basada en un delimitador de variable. Sin embargo, hay propiedades que solo se utilizan en una de las tres opciones, y la mayor parte de la lógica de división es exclusiva de su estrategia.
Sugerencia 1: Tres funciones "independientes".
Realmente tendrías que ponerlos en una clase estática o hacer algo especial con delegados / lambdas, pero en última instancia, hay poco que ganar con tener una clase grande.
public static IList<string> SplitRespectingQuotation(string sourceString, char delimiter = ' ', char quote = '"') { ... }
public static IList<string> SplitRespectingEscapes(string sourceString, char delimiter = ' ', char escape = '\') { ... }
public static IList<string> SplitPlain(string sourceString, char delimiter = ' ') { ... }
Si desea que la salida comunique los parámetros de entrada, puede crear una clase mucho más ligera que lo haga. Sus propiedades serían readonly; si necesita cambiarlos y volver a calcularlos, simplemente vuelva a llamar a la función. Después de todo, ¡eso es esencialmente lo que estás haciendo dentro de tu clase actual!
Otra ventaja: si y cuando se le ocurra una nueva estrategia para dividir, puede crear una nueva función sin afectar a las demás. Todos se pueden comprobar, editar y eliminar de forma independiente.
Sugerencia 2: Tres clases concretas que amplían una clase base abstracta.
Me gusta lo que hizo con la _Tokenspropiedad: le permite aplazar el cálculo hasta que realmente lo necesite, lo cual es útil en los casos en que no lo hará. Además, un caso de uso que admite (que no es compatible con funciones "independientes") es cambiar, por ejemplo, el carácter de escape y hacer que el resultado se "invalide" automáticamente.
Para mantener ese comportamiento, puede extraer los elementos comunes en una clase base abstracta, como la siguiente:
public abstract class StringTokenizer
{
public string SourceString { get; }
public StringTokenizer(string dataInput)
{
SourceString = dataInput ?? string.Empty;
}
public string this[int index] => index >= this.Count ? String.Empty : Tokens[index];
public int Count => Tokens.Count;
public char Delimiter
{
get { return _delimiter; }
set
{
if (value != _delimiter)
{
_delimiter = value;
InvalidateResult();
}
}
}
private char _delimiter = ' ';
public IEnumerable<string> Tokens
{
get
{
if (_tokens is null)
{
_tokens = ComputeTokens();
}
return _tokens;
}
}
private List<string> _tokens = null;
protected abstract List<string> ComputeTokens();
protected void InvalidateResult()
{
_tokens = null;
}
}
Cambios notables:
- La lógica de división real está ausente. Cada estrategia proporcionará la suya propia.
- Las propiedades específicas de la estrategia están ausentes. No es necesario que una estrategia basada en el escape tenga una propiedad para un carácter de comillas y viceversa.
- En lugar de establecer directamente
_tokens = null, las propiedades deberían llamarInvalidateResult. Esto permite_tokenshacer loprivateque mantiene la lógica contenida en la clase base. Tokenses público y es unIEnumerable. Esto permite que los consumidores utilicenforeach, pero desalienta la modificación directa.
Una clase base ahora tiene exactamente un trabajo: implementar ComputeTokens. Si necesita crear propiedades para hacerlo, puede hacerlo basándose en su propia lógica específica de la estrategia. Si esas propiedades necesitan invalidar tokens calculados previamente cuando cambian, pueden llamar InvalidateResult.
Aquí hay un ejemplo aproximado de cómo se vería una subclase de estrategia:
public sealed class EscapeStringTokenizer : StringTokenizer
{
public EscapeStringTokenizer (string dataInput) : base(dataInput) { }
public char Escape
{
get { return _escape; }
set
{
if (value != _escape)
{
_escape = value;
InvalidateResult();
}
}
}
protected override List<string> ComputeTokens()
{
// Actual logic omitted
}
}
Otras Observaciones
- Permite que se especifique el delimitador, pero siempre condensa los espacios en blanco. Si me separo
"a,a and b,b"con un delimitador de",", esperaría{"a", "a and b", "b"}volver, pero en realidad obtendría{"a", "a and b", "b"}. - Si el delimitador, etc., se puede leer públicamente, ¿por qué no exponer también la cadena de origen? Vea
SourceStringmi clase abstracta arriba. - Encuentro que los descriptores de acceso de propiedad con cuerpo de expresión (relativamente nuevos) son mejores para propiedades simples. Vea
Counten mi clase abstracta arriba. - No creo que sea posible asignar accidentalmente
nulla una variable como condición de una declaración if. Esto se debe a que sex = nullevalúa como del mismo tipo quex, que debe ser abool(y, por lo tanto, no anulable) para ser una condición válida. Si aún desea evitarlox == null, puede decirlox is null. - Como han mencionado otros, no debe anteponer propiedades con
_. No está ahí para diferenciar entre público y privado, sino entre variables locales y campos de clase. Personalmente, sin embargo, ni siquiera lo uso_en ese caso, sino que prefierothis.si es necesario. Pero en general, deberá ser flexible al respecto y asegurarse de seguir cualquier patrón que ya esté establecido en un equipo o proyecto existente. - Además, como otros han mencionado, utilícelo
varal declarar variables siempre que sea posible. Cualquier buen IDE podrá decirle el tipo cuando pase el mouse sobre la variable, y su nombre debería indicarle para qué sirve incluso sin el tipo. - En esa nota, evite nombres como
cyl.iestá bien porque es idiomático como una variable de ciclo / índice, pero las otras requieren un contexto adicional para comprenderlas. Los caracteres del código fuente son baratos, así que pague por una legibilidad adicional usandocurrentCharyfinishedTokens. - No es necesario traducir la fuente
stringachar[]; ya puede acceder a los caracteres en unstringíndice por.
No debería tener
Whitespacesuna propiedad de solo obtención, sino como unreadonlycampo privado y debería tener esa expresión regular compilada porque la está utilizando con bastante frecuencia.El uso regionse considera un anti-patrón.
Utilice prefijos de subrayado solo para campos privados. No los use para métodos o propiedades.
Si el tipo de una variable está claro en el lado derecho de una asignación, debe usar en
varlugar del tipo concreto.El código está funcionando mucho, aunque
_sourceStringpuede deberse astring.Emptyque el argumento ctor pasadodataInputpuede sernullostring.Empty. Preferiría lanzar una excepción enctor.En lugar de asignar una variable a otra y luego manipular la variable resultante, puede hacerlo en una línea como, por ejemplo,
string data = Whitespaces.Replace(_sourceString, " ");en vez de
string data = _sourceString; // Doing some basic transformations data = Whitespaces.Replace(data, " ");Si solo necesita acceder a elementos individuales de una matriz y no necesita mirar hacia adelante, debería preferir
foreachunforbucle en lugar de un bucle.
Un
lnombre de una sola letra me parece malo.Creo que debería agregar un mensaje a la excepción que describa el motivo del error.
De forma predeterminada, elimina todos los espacios en blanco de los datos. Pero pueden ser necesarios dentro de las fichas. Puede hacer una opción adicional para especificar esto.
Gracias a todos por los excelentes comentarios. He adoptado la mayoría de los cambios en mi código que se aloja como FOS enhttps://github.com/pillepalle1/dotnet-pillepalle1 donde recibirá más mantenimiento.
Por ahora, he empaquetado la lógica de división en tres métodos de extensión estáticos. Además, he construido envoltorios según lo sugerido por therubberduck para mantener opcionalmente la comodidad de la invalidación automática de tokens.
Sugerencias que he implementado
Denominación de variables Los nombres de variables, como los que
lhan sido reemplazados por nombres más descriptivosSe han agregado mensajes de excepción
La modificación del contenido del token se ha eliminado por completo de los métodos de extensión y está disponible opcionalmente en los contenedores.
Las regiones se han eliminado por completo
Usar var siempre que sea razonable / posible
Bucles Preferir
foreachsobreforbucles e iterar sobre el ensourceStringlugar de convertirlo alchar[]primeroInputstring Throwing en
ArgumentNullExceptionlugar de convertirnullaString.EmptyDivisión de CSV según RFC4180
Habría adoptado más cambios, pero algunas sugerencias (es decir, con respecto a las Whitespacespropiedades corporales de expresión) se han vuelto obsoletas en la nueva implementación.
Sugerencias que no he implementado
- El nombre de subrayado para todo lo privado / protegido me parece más razonable que solo distinguir entre variables miembro y locales, ya que al implementar estructuras de datos robustas de concurrencia (que se convirtió en una gran cosa desde que
Tasksse implementaron) es increíblemente valioso ver a primera vista si un El método realiza comprobaciones de concurrencia (públicas) o no (privadas).
Código
Métodos de tokenizador estático
using System;
using System.Text;
using System.Collections.Immutable;
namespace pillepalle1.Text
{
public static class StringTokenizer
{
private static FormatException _nonQuotedTokenMayNotContainQuotes =
new FormatException("[RFC4180] If fields are not enclosed with double quotes, then double quotes may not appear inside the fields.");
private static FormatException _quotesMustBeEscapedException =
new FormatException("[RFC4180] If double-quotes are used to enclose fields, then a double-quote appearing inside a field must be escaped by preceding it with another double quote.");
private static FormatException _tokenNotFullyEnclosed =
new FormatException("[RFC4180] \"Each field may or may not be enclosed in double quotes\". However, for the final field the closing quotes are missing.");
/// <summary>
/// <para>
/// Formats and splits the tokens by delimiter allowing to add delimiters by quoting
/// similar to https://tools.ietf.org/html/rfc4180
/// </para>
///
/// <para>
/// Each field may or may not be enclosed in double quotes (however some programs, such as
/// Microsoft Excel, do not use double quotes at all). If fields are not enclosed with
/// double quotes, then double quotes may not appear inside the fields.
/// </para>
///
/// <para>
/// Fields containing line breaks (CRLF), double quotes, and commas should be enclosed in
/// double-quotes.
/// </para>
///
/// <para>
/// If double-quotes are used to enclose fields, then a double-quote appearing inside a
/// field must be escaped by preceding it with another double quote.
/// </para>
///
/// <para>
/// The ABNF defines
///
/// [field = (escaped / non-escaped)] ||
/// [non-escaped = *TEXTDATA] ||
/// [TEXTDATA = %x20-21 / %x23-2B / %x2D-7E]
///
/// specifically forbidding to include quotes in non-escaped fields, hardening the *SHOULD*
/// requirement above.
/// </para>
/// </summary>
public static ImmutableList<string> SplitRespectingQuotation(this string sourceString, char delimiter = ' ', char quotes = '"')
{
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Initialisation
var tokenList = ImmutableList<string>.Empty;
var tokenBuilder = new StringBuilder();
var expectingDelimiterOrQuotes = false; // Next char must be Delimiter or Quotes
var hasReadTokenChar = false; // We are not between tokens (=> No quoting)
var isQuoting = false;
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Scan character by character
foreach (char c in sourceString)
{
if (expectingDelimiterOrQuotes)
{
expectingDelimiterOrQuotes = false;
if (c == delimiter)
{
isQuoting = false;
}
else if (c == quotes)
{
tokenBuilder.Append(c);
hasReadTokenChar = true;
continue;
}
else
{
throw _quotesMustBeEscapedException;
}
}
// -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
if (c == quotes)
{
if (isQuoting)
{
expectingDelimiterOrQuotes = true;
}
else
{
if (hasReadTokenChar)
{
throw _nonQuotedTokenMayNotContainQuotes;
}
isQuoting = true;
}
}
else if (c == delimiter)
{
if (isQuoting)
{
tokenBuilder.Append(c);
hasReadTokenChar = true;
}
else
{
tokenList = tokenList.Add(tokenBuilder.ToString());
tokenBuilder.Clear();
hasReadTokenChar = false;
}
}
// Any other character is just being appended to
else
{
tokenBuilder.Append(c);
hasReadTokenChar = true;
}
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Tidy up open flags and checking consistency
tokenList = tokenList.Add(tokenBuilder.ToString());
if (isQuoting && !expectingDelimiterOrQuotes)
{
throw _tokenNotFullyEnclosed;
}
return tokenList;
}
/// <summary>
/// Splits the string by declaring one character as escape
/// </summary>
public static ImmutableList<string> SplitRespectingEscapes(this string sourceString, char delimiter = ' ', char escapeChar = '\\')
{
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Initialisation
var tokenList = ImmutableList<string>.Empty;
var tokenBuilder = new StringBuilder();
var escapeNext = false;
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Scan character by character
foreach (char c in sourceString)
{
if (escapeNext)
{
tokenBuilder.Append(c);
escapeNext = false;
continue;
}
if (c == escapeChar)
{
escapeNext = true;
}
else if (c == delimiter)
{
tokenList = tokenList.Add(tokenBuilder.ToString());
tokenBuilder.Clear();
}
else
{
tokenBuilder.Append(c);
}
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Tidy up open flags and checking consistency
tokenList = tokenList.Add(tokenBuilder.ToString());
if (escapeNext) throw new FormatException(); // Expecting additional char
return tokenList;
}
/// <summary>
/// Splits the string by calling a simple String.Split
/// </summary>
public static ImmutableList<string> SplitPlain(this string sourceString, char delimiter = ' ')
{
return ImmutableList<string>.Empty.AddRange(sourceString.Split(delimiter));
}
}
}
Clase base de contenedor abstracto
using System;
using System.Collections.Immutable;
namespace pillepalle1.Text
{
public abstract class AStringTokenizer
{
public AStringTokenizer()
{
}
public AStringTokenizer(string sourceString)
{
SourceString = sourceString;
}
/// <summary>
/// String that is supposed to be split in tokens
/// </summary>
public string SourceString
{
get
{
return _sourceString;
}
set
{
if (null == value)
{
throw new ArgumentNullException("Cannot split null in tokens");
}
else if (_sourceString.Equals(value))
{
// nop
}
else
{
_sourceString = value;
_InvalidateTokens();
}
}
}
private string _sourceString = String.Empty;
/// <summary>
/// Character indicating how the source string is supposed to be split
/// </summary>
public char Delimiter
{
get
{
return _delimiter;
}
set
{
if (value != _delimiter)
{
_delimiter = value;
_InvalidateTokens();
}
}
}
private char _delimiter = ' ';
/// <summary>
/// Flag indicating whether whitespaces should be removed from start and end of each token
/// </summary>
public bool TrimTokens
{
get
{
return _trimTokens;
}
set
{
if (value != _trimTokens)
{
_trimTokens = value;
_InvalidateTokens();
}
}
}
private bool _trimTokens = false;
/// <summary>
/// Result of tokenization
/// </summary>
public ImmutableList<string> Tokens
{
get
{
if (null == _tokens)
{
_tokens = Tokenize();
if (TrimTokens)
{
_tokens = _TrimTokens(_tokens);
}
}
return _tokens;
}
}
private ImmutableList<string> _tokens = null;
/// <summary>
/// Split SourceString into tokens
/// </summary>
protected abstract ImmutableList<string> Tokenize();
/// <summary>
/// Trims whitespaces from tokens
/// </summary>
/// <param name="candidates">List of tokens</param>
private ImmutableList<string> _TrimTokens(ImmutableList<string> candidates)
{
var trimmedTokens = ImmutableList<string>.Empty;
foreach (var token in candidates)
{
trimmedTokens = trimmedTokens.Add(token.Trim());
}
return trimmedTokens;
}
/// <summary>
/// Invalidate and recompute tokens if necessary
/// </summary>
protected void _InvalidateTokens()
{
_tokens = null;
}
}
}
Envoltorio para tokenización simple
using System.Collections.Immutable;
namespace pillepalle1.Text
{
public class PlainStringTokenizer : AStringTokenizer
{
protected override ImmutableList<string> Tokenize()
{
return SourceString.SplitPlain(Delimiter);
}
}
}
Envoltorio para tokenización de cotizaciones
using System.Collections.Immutable;
namespace pillepalle1.Text
{
public class QuotationStringTokenizer : AStringTokenizer
{
/// <summary>
/// Indicates which character is used to encapsulate tokens
/// </summary>
public char Quotes
{
get
{
return _quotes;
}
set
{
if (value != _quotes)
{
_quotes = value;
_InvalidateTokens();
}
}
}
private char _quotes = '"';
protected override ImmutableList<string> Tokenize()
{
return SourceString.SplitRespectingQuotation(Delimiter, Quotes);
}
}
}
Envoltorio para tokenización de escape
using System.Collections.Immutable;
namespace pillepalle1.Text
{
public class EscapedStringTokenizer : AStringTokenizer
{
/// <summary>
/// Indicates which character is used to escape characters
/// </summary>
public char Escape
{
get
{
return _escape;
}
set
{
if (value != _escape)
{
_escape = value;
_InvalidateTokens();
}
}
}
private char _escape = '"';
protected override ImmutableList<string> Tokenize()
{
return SourceString.SplitRespectingEscapes(Delimiter, Escape);
}
}
}