Last active
November 20, 2023 13:42
-
-
Save Guiorgy/0fd0cffdaca2d78fdd910491511467cb to your computer and use it in GitHub Desktop.
Convert a DateTime object to and from an ISO string faster than the builtin methods
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
namespace DateTimeIso; | |
public static class DateTimeIsoExtensions | |
{ | |
/// <summary> | |
/// Converts <see cref="DateTime"/> into an ISO 8601:2004 string (<c>yyyy-MM-dd HH:mm:ss.fff</c>) faster than <see cref="DateTime.ToString"/>. | |
/// </summary> | |
/// <param name="dateTime">the <see cref="DateTime"/> to be converted.</param> | |
/// <param name="milliseconds">if <see langword="false"/>, milliseconds won't be shown (<c>yyyy-MM-dd HH:mm:ss</c>).</param> | |
/// <param name="strictDateTimeDelimiter">if <see langword="true"/>, <c>'T'</c> will be used as the delimiter between date and time as per ISO 8601-1:2019 (<c>yyyy-MM-ddTHH:mm:ss.fff</c>).</param> | |
/// <param name="omitDelimiters">if <see langword="true"/>, delimiters will be omited, except for <c>'T'</c> between date and time when | |
/// <paramref name="strictDateTimeDelimiter"/> is <see langword="true"/> (<c>yyyyMMddHHmmssfff</c>, <c>yyyyMMddTHHmmssfff</c>).</param> | |
/// <returns>The <see cref="DateTime"/> in the ISO 8601:2004 format.</returns> | |
public static string ToIsoString(this DateTime dateTime, bool milliseconds = false, bool strictDateTimeDelimiter = false, bool omitDelimiters = false) | |
{ | |
static char DigitToAsciiChar(int digit) => (char)('0' + digit); | |
static void Write2Digits(Span<char> chars, int offset, int value) | |
{ | |
int firstDigit = value / 10; | |
int secondDigit = value - (firstDigit * 10); | |
chars[offset] = DigitToAsciiChar(firstDigit); | |
chars[offset + 1] = DigitToAsciiChar(secondDigit); | |
} | |
static void Write2DigitsAndPostfix(Span<char> chars, int offset, int value, char postfix) | |
{ | |
Write2Digits(chars, offset, value); | |
chars[offset + 2] = postfix; | |
} | |
static void Write3Digits(Span<char> chars, int offset, int value) | |
{ | |
int firstDigit = value / 100; | |
value -= firstDigit * 100; | |
int secondDigit = value / 10; | |
int thirdDigit = value - (secondDigit * 10); | |
chars[offset] = DigitToAsciiChar(firstDigit); | |
chars[offset + 1] = DigitToAsciiChar(secondDigit); | |
chars[offset + 2] = DigitToAsciiChar(thirdDigit); | |
} | |
/*static void Write3DigitsAndPostrfix(Span<char> chars, int offset, int value, char postfix) | |
{ | |
Write3Digits(chars, offset, value); | |
chars[offset + 3] = postfix; | |
}*/ | |
static void Write4Digits(Span<char> chars, int offset, int value) | |
{ | |
int firstDigit = value / 1000; | |
value -= firstDigit * 1000; | |
int secondDigit = value / 100; | |
value -= secondDigit * 100; | |
int thirdDigit = value / 10; | |
int fourthDigit = value - (thirdDigit * 10); | |
chars[offset] = DigitToAsciiChar(firstDigit); | |
chars[offset + 1] = DigitToAsciiChar(secondDigit); | |
chars[offset + 2] = DigitToAsciiChar(thirdDigit); | |
chars[offset + 3] = DigitToAsciiChar(fourthDigit); | |
} | |
static void Write4DigitsAndPostfix(Span<char> chars, int offset, int value, char postfix) | |
{ | |
Write4Digits(chars, offset, value); | |
chars[offset + 4] = postfix; | |
} | |
if (omitDelimiters) | |
{ | |
int length = 14 + (strictDateTimeDelimiter ? 1 : 0) + (milliseconds ? 3 : 0); | |
return string.Create(length, (dateTime, strictDateTimeDelimiter, milliseconds), (chars, state) => | |
{ | |
(var _dateTime, var _strictDelimiter, var _milliseconds) = state; | |
Write4Digits(chars, 0, _dateTime.Year); | |
Write2Digits(chars, 4, _dateTime.Month); | |
if (_strictDelimiter) Write2DigitsAndPostfix(chars, 6, _dateTime.Day, 'T'); | |
else Write2Digits(chars, 6, _dateTime.Day); | |
int tOffset = _strictDelimiter ? 1 : 0; | |
Write2Digits(chars, 8 + tOffset, _dateTime.Hour); | |
Write2Digits(chars, 10 + tOffset, _dateTime.Minute); | |
Write2Digits(chars, 12 + tOffset, _dateTime.Second); | |
if (_milliseconds) Write3Digits(chars, 14 + tOffset, _dateTime.Millisecond); | |
}); | |
} | |
else | |
{ | |
int length = 19 + (milliseconds ? 4 : 0); | |
return string.Create(length, (dateTime, strictDateTimeDelimiter, milliseconds), (chars, state) => | |
{ | |
(var _dateTime, var _strictDelimiter, var _milliseconds) = state; | |
Write4DigitsAndPostfix(chars, 0, _dateTime.Year, '-'); | |
Write2DigitsAndPostfix(chars, 5, _dateTime.Month, '-'); | |
Write2DigitsAndPostfix(chars, 8, _dateTime.Day, _strictDelimiter ? 'T' : ' '); | |
Write2DigitsAndPostfix(chars, 11, _dateTime.Hour, ':'); | |
Write2DigitsAndPostfix(chars, 14, _dateTime.Minute, ':'); | |
if (_milliseconds) | |
{ | |
Write2DigitsAndPostfix(chars, 17, _dateTime.Second, '.'); | |
Write3Digits(chars, 20, _dateTime.Millisecond); | |
} | |
else | |
{ | |
Write2Digits(chars, 17, _dateTime.Second); | |
} | |
}); | |
} | |
} | |
/// <summary> | |
/// Converts the ISO 8601:2004 (<c>yyyy-MM-dd HH:mm:ss.fff</c>) string representation of a date and time to its <see cref="DateTime"/> equivalent faster than <see cref="DateTime.TryParseExact"/>. | |
/// </summary> | |
/// <param name="isoDateTimeString">The string containing a date and time to convert.</param> | |
/// <param name="milliseconds">Whether the string contains milliseconds.</param> | |
/// <param name="noDateTimeDelimiter">if <see langword="true"/>, the delimiter between date and time (<c>'T'</c>) is assumed to be omitted (<c>yyyy-MM-ddHH:mm:ss.fff</c>).</param> | |
/// <param name="noDelimiters">if <see langword="true"/>, all delimiters, except for <c>'T'</c> between date and time when | |
/// <paramref name="noDateTimeDelimiter"/> is <see langword="false"/>, are assumed to be omitted (<c>yyyyMMddHHmmssfff</c>, <c>yyyyMMddTHHmmssfff</c>).</param> | |
/// <returns><see langword="true"/> if <paramref name="isoDateTimeString"/> was converted successfully; otherwise, <see langword="false"/>.</returns> | |
public static bool TryParseIsoDateTime(this string isoDateTimeString, out DateTime dateTime, bool milliseconds = false, bool noDateTimeDelimiter = false, bool noDelimiters = false) | |
{ | |
int expectedLength = (milliseconds, noDateTimeDelimiter, noDelimiters) switch | |
{ | |
(false, false, false) => 19, // yyyy-MM-ddTHH:mm:ss | |
(true, false, false) => 23, // yyyy-MM-ddTHH:mm:ss.fff | |
(false, false, true) => 15, // yyyyMMddTHHmmss | |
(true, false, true) => 18, // yyyyMMddTHHmmssfff | |
(false, true, false) => 18, // yyyy-MM-ddHH:mm:ss | |
(true, true, false) => 22, // yyyy-MM-ddHH:mm:ss.fff | |
(false, true, true) => 14, // yyyyMMddHHmmss | |
(true, true, true) => 17, // yyyyMMddHHmmssfff | |
}; | |
if (isoDateTimeString == null || isoDateTimeString.Length != expectedLength) | |
{ | |
dateTime = default; | |
return false; | |
} | |
static int AsciiCharToDigit(char digit) => digit - '0'; | |
static bool IsDigit(int i) => 0 <= i && i <= 9; | |
static bool TryRead4Digits(ref ReadOnlySpan<char> chars, bool skipNext, out int value) | |
{ | |
int a = AsciiCharToDigit(chars[0]); | |
int b = AsciiCharToDigit(chars[1]); | |
int c = AsciiCharToDigit(chars[2]); | |
int d = AsciiCharToDigit(chars[3]); | |
if (!IsDigit(a) || !IsDigit(b) || !IsDigit(c) || !IsDigit(d)) | |
{ | |
value = 0; | |
return false; | |
} | |
chars = chars[(skipNext ? 5 : 4)..]; | |
value = (a * 1000) + (b * 100) + (c * 10) + d; | |
return true; | |
} | |
static bool TryRead3Digits(ref ReadOnlySpan<char> chars, bool skipNext, out int value) | |
{ | |
int a = AsciiCharToDigit(chars[0]); | |
int b = AsciiCharToDigit(chars[1]); | |
int c = AsciiCharToDigit(chars[2]); | |
if (!IsDigit(a) || !IsDigit(b) || !IsDigit(c)) | |
{ | |
value = 0; | |
return false; | |
} | |
chars = chars[(skipNext ? 4 : 3)..]; | |
value = (a * 100) + (b * 10) + c; | |
return true; | |
} | |
static bool TryRead2Digits(ref ReadOnlySpan<char> chars, bool skipNext, out int value) | |
{ | |
int a = AsciiCharToDigit(chars[0]); | |
int b = AsciiCharToDigit(chars[1]); | |
if (!IsDigit(a) || !IsDigit(b)) | |
{ | |
value = 0; | |
return false; | |
} | |
chars = chars[(skipNext ? 3 : 2)..]; | |
value = (a * 10) + b; | |
return true; | |
} | |
ReadOnlySpan<char> isoSpan = isoDateTimeString.AsSpan(); | |
int millisecond = 0; | |
if (TryRead4Digits(ref isoSpan, !noDelimiters, out int year) | |
&& TryRead2Digits(ref isoSpan, !noDelimiters, out int month) | |
&& TryRead2Digits(ref isoSpan, !noDateTimeDelimiter, out int day) | |
&& TryRead2Digits(ref isoSpan, !noDelimiters, out int hour) | |
&& TryRead2Digits(ref isoSpan, !noDelimiters, out int minute) | |
&& TryRead2Digits(ref isoSpan, !noDelimiters && milliseconds, out int second) | |
&& (!milliseconds || TryRead3Digits(ref isoSpan, false, out millisecond))) | |
{ | |
dateTime = new DateTime(year, month, day, hour, minute, second, millisecond, DateTimeKind.Unspecified); | |
return true; | |
} | |
else | |
{ | |
dateTime = default; | |
return false; | |
} | |
} | |
/// <summary> | |
/// Converts the ISO 8601:2004 (<c>yyyy-MM-dd HH:mm:ss.fff</c>) string representation of a date and time to its <see cref="DateTime"/> equivalent faster than <see cref="DateTime.TryParseExact"/>. | |
/// </summary> | |
/// <param name="isoDateTimeString">The string containing a date and time to convert.</param> | |
/// <param name="milliseconds">Whether the string contains milliseconds.</param> | |
/// <param name="noDateTimeDelimiter">if <see langword="true"/>, the delimiter between date and time (<c>'T'</c>) is assumed to be omitted (<c>yyyy-MM-ddHH:mm:ss.fff</c>).</param> | |
/// <param name="noDelimiters">if <see langword="true"/>, all delimiters, except for <c>'T'</c> between date and time when | |
/// <paramref name="noDateTimeDelimiter"/> is <see langword="false"/>, are assumed to be omitted (<c>yyyyMMddHHmmssfff</c>, <c>yyyyMMddTHHmmssfff</c>).</param> | |
/// <returns>An object that is equivalent to the date and time contained in <paramref name="isoDateTimeString"/>.</returns> | |
/// <exception cref="ArgumentNullException"><paramref name="isoDateTimeString"/> is null.</exception> | |
/// <exception cref="FormatException"><paramref name="isoDateTimeString"/> is not in the correct ISO format.</exception> | |
public static DateTime ParseIsoDateTime(this string isoDateTimeString, bool milliseconds = false, bool noDateTimeDelimiter = false, bool noDelimiters = false) | |
{ | |
if (isoDateTimeString == null) throw new ArgumentNullException(nameof(isoDateTimeString)); | |
if (!TryParseIsoDateTime(isoDateTimeString, out DateTime dateTime, milliseconds, noDateTimeDelimiter, noDelimiters)) | |
throw new FormatException("String is not in the correct ISO format"); | |
return dateTime; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using BenchmarkDotNet.Attributes; | |
using System.Globalization; | |
namespace DateTimeIso; | |
[ReturnValueValidator(failOnError: true)] | |
public class FromStringBenchmark | |
{ | |
private const int _count = 10000; | |
public readonly struct Data | |
{ | |
public required readonly string[] Dates { get; init; } | |
public required string BuiltinFormat { get; init; } | |
public required bool Milliseconds { get; init; } | |
public required bool NoDateTimeDelimiter { get; init; } | |
public required bool NoDelimiters { get; init; } | |
public static implicit operator Data((string builtinFormat, bool milliseconds, bool noDateTimeDelimiter, bool noDelimiters) tuple) | |
{ | |
RandomDateTime _random = new(42); | |
var dates = new string[_count]; | |
for (int i = 0; i < _count; i++) dates[i] = _random.Next().ToString(tuple.builtinFormat); | |
return new Data | |
{ | |
Dates = dates, | |
BuiltinFormat = tuple.builtinFormat, | |
Milliseconds = tuple.milliseconds, | |
NoDateTimeDelimiter = tuple.noDateTimeDelimiter, | |
NoDelimiters = tuple.noDelimiters | |
}; | |
} | |
public override string ToString() | |
{ | |
return BuiltinFormat; | |
} | |
} | |
[ParamsSource(nameof(DataValues))] | |
public Data data; | |
public static IEnumerable<Data> DataValues => new Data[] | |
{ | |
("yyyy-MM-ddTHH:mm:ss.fff", true, false, false), | |
("yyyy-MM-ddTHH:mm:ss", false, false, false), | |
("yyyy-MM-ddHH:mm:ss.fff", true, true, false), | |
("yyyy-MM-ddHH:mm:ss", false, true, false), | |
("yyyyMMddTHHmmssfff", true, false, true), | |
("yyyyMMddTHHmmss", false, false, true), | |
("yyyyMMddHHmmssfff", true, true, true), | |
("yyyyMMddHHmmss", false, true, true) | |
}; | |
[Benchmark(Baseline = true)] | |
public DateTime[] System_DateTime_TryParseExact() | |
{ | |
DateTime[] parsed = new DateTime[_count]; | |
for (int i = 0; i < _count; i++) | |
DateTime.TryParseExact(data.Dates[i], data.BuiltinFormat, CultureInfo.InvariantCulture, DateTimeStyles.None, out parsed[i]); | |
return parsed; | |
} | |
[Benchmark] | |
public DateTime[] Extension_TryParseIsoDateTime() | |
{ | |
DateTime[] parsed = new DateTime[_count]; | |
for (int i = 0; i < _count; i++) | |
data.Dates[i].TryParseIsoDateTime(out parsed[i], data.Milliseconds, data.NoDateTimeDelimiter, data.NoDelimiters); | |
return parsed; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
namespace DateTimeIso; | |
public sealed class RandomDateTime | |
{ | |
private static readonly DateTime start = new(1995, 1, 1, 0, 0, 0, DateTimeKind.Unspecified); | |
private static readonly DateTime end = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Unspecified); | |
private static readonly int daysRange = (end - start).Days; | |
private readonly Random generator; | |
public RandomDateTime(int? seed = null) | |
{ | |
generator = seed == null ? new Random() : new Random((int)seed); | |
} | |
public DateTime Next() | |
{ | |
return start | |
.AddMilliseconds(generator.Next(0, 1000)) | |
.AddSeconds(generator.Next(0, 60)) | |
.AddMinutes(generator.Next(0, 60)) | |
.AddHours(generator.Next(0, 24)) | |
.AddDays(generator.Next(daysRange)); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using BenchmarkDotNet.Attributes; | |
namespace DateTimeIso; | |
[ReturnValueValidator(failOnError: true)] | |
public class ToStringBenchmark | |
{ | |
private DateTime[] _dates = Array.Empty<DateTime>(); | |
private const int _count = 10000; | |
public readonly struct Data | |
{ | |
public required string BuiltinFormat { get; init; } | |
public required bool Milliseconds { get; init; } | |
public required bool StrictDateTimeDelimiter { get; init; } | |
public required bool OmitDelimiters { get; init; } | |
public static implicit operator Data((string builtinFormat, bool milliseconds, bool strictDateTimeDelimiter, bool omitDelimiters) tuple) | |
{ | |
return new Data | |
{ | |
BuiltinFormat = tuple.builtinFormat, | |
Milliseconds = tuple.milliseconds, | |
StrictDateTimeDelimiter = tuple.strictDateTimeDelimiter, | |
OmitDelimiters = tuple.omitDelimiters | |
}; | |
} | |
public override string ToString() | |
{ | |
return BuiltinFormat; | |
} | |
} | |
[ParamsSource(nameof(DataValues))] | |
public Data data; | |
public static IEnumerable<Data> DataValues => new Data[] | |
{ | |
("yyyy-MM-dd HH:mm:ss.fff", true, false, false), | |
("yyyy-MM-dd HH:mm:ss", false, false, false), | |
("yyyy-MM-ddTHH:mm:ss.fff", true, true, false), | |
("yyyy-MM-ddTHH:mm:ss", false, true, false), | |
("yyyyMMddHHmmssfff", true, false, true), | |
("yyyyMMddHHmmss", false, false, true), | |
("yyyyMMddTHHmmssfff", true, true, true), | |
("yyyyMMddTHHmmss", false, true, true) | |
}; | |
[GlobalSetup] | |
public void Setup() | |
{ | |
_dates = new DateTime[_count]; | |
RandomDateTime _random = new(42); | |
for (int i = 0; i < _count; i++) _dates[i] = _random.Next(); | |
} | |
[Benchmark(Baseline = true)] | |
public string[] System_DateTime_ToString() | |
{ | |
string[] formated = new string[_count]; | |
for (int i = 0; i < _count; i++) | |
formated[i] = _dates[i].ToString(data.BuiltinFormat); | |
return formated; | |
} | |
[Benchmark] | |
public string[] Extension_ToIsoString() | |
{ | |
string[] formated = new string[_count]; | |
for (int i = 0; i < _count; i++) | |
formated[i] = _dates[i].ToIsoString(data.Milliseconds, data.StrictDateTimeDelimiter, data.OmitDelimiters); | |
return formated; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Setup
ToString
TryParse