Before .NET introduced ReadOnlySpan<T>, a parsing method would typically look like T Parse(string str). Within the body, you would use an int index to keep track of your position as you parse the syntactic parts of the string. For more flexibility, you might expose T Parse(string str, int startIndex, int length) as to not require a new string to be created if its characters existed within a larger string, though this requires more careful handling of various indices and lengths within the implementation.
With ReadOnlySpan<T>, we can use the improved T Parse(ReadOnlySpan<char> str) signature, but the implementation still requires tedious tracking of indices. This article introduces the TryConsume pattern as a less error-prone way to implement parsing methods with ReadOnlySpan<char>.
The idea is to use a series of helper methods of the form: T TryConsume(ref ReadOnlySpan<char> str, ...). These methods attempt to match a prefix in the string. If there is a match, a value is returned indicating the matched value and the ReadOnlySpan<char> is updated to the remainder of the string. If there is no match, false, null or a similar value is returned and the ReadOnlySpan<char> is left unchanged. For example:
static bool TryConsume(ref ReadOnlySpan<char> str, char c);
static bool TryConsume(ref ReadOnlySpan<char> str, string prefix);
static int? TryConsumeInt(ref ReadOnlySpan<char> str);
static bool SkipWhiteSpace(ref ReadOnlySpan<char> str); // Named "Skip" because we don't care what exact string was matched
The Maui user interface framework supports a short syntax for defining a grid layout with rows of different sizes:
<Grid RowDefinitions="1*, Auto, 25, 14, 20"/>
The value of RowDefinitions is a comma-separated of one of three kinds of sizes: Auto (fit to content), a size in pixels, or a size in stars (used for proportional sizing). For simplicity, assume that the sizes are integers. This format could be parsed using a regular expression, but it would be somewhat awkward. With the TryConsume pattern, it becomes as follows:
static GridLength[] ParseGridLengths(ReadOnlySpan<char> str)
{
List<GridLength> lengths = new();
for (;;)
{
// Skip any whitespace before a field
SkipWhiteSpace(ref str);
if (str.Length == 0) break;
// Parse one of the supported length types
if (TryConsumeInt(ref str) is int value)
{
var unit = TryConsume(ref str, '*') ? GridUnitType.Star : GridUnitType.Pixel;
lengths.Add(new GridLength(value, unit));
}
else if (TryConsume(ref str, "Auto"))
{
lengths.Add(GridLength.Auto);
}
else throw new FormatException("Expected a length!");
// Either we're at the end of the string, or there's a comma and potentially more lengths
SkipWhiteSpace(ref str);
if (str.Length == 0) break;
if (!TryConsume(ref str, ',')) throw new FormatException("Expected a comma!");
}
return lengths.ToArray();
}
The .NET standard library does not expose type parsing with the TryConsume pattern. If this brings you to implement methods like TryConsumeFloat yourself, you should consider the internationalization implications of supporting different regional formats.
With simpler formats, regular expressions will often be a simpler alternative to the TryConsume pattern. For more complex formats, however, they may become unwieldly, be unable to express recursive structures, or make it difficult to produce useful error messages. They may also involve additional costs or memory allocations that could be avoided with the TryConsume pattern.
Because ReadOnlySpan<char> requires that the underlying chars be in contiguous memory, this approach works best for smaller formats which would be expected to be loaded in a string, such as a date, an email, an url. Think of classes and structs which have both a ToString() and a static Parse() method. For textual data that could potentially be large like a json file, a source code file or a markdown document, it would be preferable to base the parsing logic on TextReader, which supports streaming via StreamReader.
When parsing lists, it can be natural to use IEnumerable<T> and yield return statements, known as iterator methods. Unfortunately, this will not work for a method with a ReadOnlySpan<T> as a parameter because its ref struct nature constraints it to living on the stack, and iterators implicitly lift their parameters and locals into the fields of a compiler-generated class. An alternative may be to use ReadOnlyMemory<char> in the method signature and using ReadOnlySpan<char>-based helper methods within the implementation, so long as none of their lifetimes cross a yield return statement.
You may want to report the index of any invalid syntax you encounter. The TryConsume pattern updates and shrinks your ReadOnlySpan<char> as you go, so you lose the context of the current substring within the original string. If you stick to the TryConsume pattern and only consume string prefixes, you can store the original ReadOnlySpan<char> before you begin parsing, and find your current index as fullStr.Length - remainingStr.Length.