Skip to content

Commit

Permalink
Add support for other numeric types using generic math in .NET 7+ (#15)
Browse files Browse the repository at this point in the history
  • Loading branch information
Arasfon authored Aug 30, 2023
1 parent 1fb3bff commit 59812c2
Show file tree
Hide file tree
Showing 7 changed files with 335 additions and 24 deletions.
160 changes: 154 additions & 6 deletions src/Sqids/SqidsEncoder.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,25 @@
#if NET7_0_OR_GREATER
using System.Numerics;
#endif

namespace Sqids;

#if NET7_0_OR_GREATER
/// <summary>
/// The Sqids encoder/decoder. This is the main class.
/// </summary>
/// <typeparam name="T">
/// The integral numeric type that will be encoded/decoded.
/// Could be one of `int`, `long`, `byte`, `short`, and others. For the full list, check out
/// https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/integral-numeric-types
/// </typeparam>
public sealed class SqidsEncoder<T> where T : unmanaged, IBinaryInteger<T>, IMinMaxValue<T>
#else
/// <summary>
/// The Sqids encoder/decoder. This is the main class.
/// </summary>
public sealed class SqidsEncoder
#endif
{
private const int MinAlphabetLength = 5;
private const int MaxStackallocSize = 256; // NOTE: In bytes — this value is essentially arbitrary, the Microsoft docs is using 1024 but recommends being more conservative when choosing the value (https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/stackalloc), Hashids apparently uses 512 (https://github.com/ullmark/hashids.net/blob/9b1c69de4eedddf9d352c96117d8122af202e90f/src/Hashids.net/Hashids.cs#L17), and this article (https://vcsjones.dev/stackalloc/) uses 256. I've tried to be pretty cautious and gone with a low value.
Expand All @@ -12,23 +28,56 @@ public sealed class SqidsEncoder
private readonly int _minLength;
private readonly string[] _blockList;

#if NET7_0_OR_GREATER
/// <summary>
/// The minimum numeric value that can be encoded/decoded using <see cref="SqidsEncoder{T}" />.
/// This is always zero across all ports of Sqids.
/// </summary>
public static T MinValue => T.Zero;
#else
/// <summary>
/// The minimum numeric value that can be encoded/decoded using <see cref="SqidsEncoder" />.
/// This is always zero across all ports of Sqids.
/// </summary>
public const int MinValue = 0;
#endif

#if NET7_0_OR_GREATER
/// <summary>
/// The maximum numeric value that can be encoded/decoded using <see cref="SqidsEncoder{T}" />.
/// This is equal to `T.MaxValue`.
/// </summary>
public static T MaxValue => T.MaxValue;
#else
/// <summary>
/// The maximum numeric value that can be encoded/decoded using <see cref="SqidsEncoder" />.
/// It's equal to `int.MaxValue`.
/// This is equal to `int.MaxValue`.
/// </summary>
public const int MaxValue = int.MaxValue;
#endif

#if NET7_0_OR_GREATER
/// <summary>
/// Initializes a new instance of <see cref="SqidsEncoder{T}" /> with the default options.
/// </summary>
#else
/// <summary>
/// Initializes a new instance of <see cref="SqidsEncoder" /> with the default options.
/// </summary>
#endif
public SqidsEncoder() : this(new()) { }


#if NET7_0_OR_GREATER
/// <summary>
/// Initializes a new instance of <see cref="SqidsEncoder{T}" /> with custom options.
/// </summary>
/// <param name="options">
/// The custom options.
/// All properties of <see cref="SqidsOptions" /> are optional and will fall back to their
/// defaults if not explicitly set.
/// </param>
#else
/// <summary>
/// Initializes a new instance of <see cref="SqidsEncoder" /> with custom options.
/// </summary>
Expand All @@ -37,6 +86,7 @@ public SqidsEncoder() : this(new()) { }
/// All properties of <see cref="SqidsOptions" /> are optional and will fall back to their
/// defaults if not explicitly set.
/// </param>
#endif
public SqidsEncoder(SqidsOptions options)
{
if (options.Alphabet.Length < MinAlphabetLength)
Expand All @@ -45,7 +95,11 @@ public SqidsEncoder(SqidsOptions options)
if (options.Alphabet.Distinct().Count() != options.Alphabet.Length)
throw new ArgumentException("The alphabet must not contain duplicate characters.");

#if NET7_0_OR_GREATER
if (T.CreateChecked(options.MinLength) < MinValue || options.MinLength > options.Alphabet.Length)
#else
if (options.MinLength < MinValue || options.MinLength > options.Alphabet.Length)
#endif
throw new ArgumentException($"The minimum length must be between {MinValue} and {options.Alphabet.Length}.");

_minLength = options.MinLength;
Expand All @@ -61,7 +115,8 @@ public SqidsEncoder(SqidsOptions options)
);
_blockList = options.BlockList.ToArray(); // NOTE: Arrays are faster to iterate than HashSets, so we construct an array here.

Span<char> shuffledAlphabet = options.Alphabet.Length * sizeof(char) > MaxStackallocSize // NOTE: We multiply the number of characters by the size of a `char` to get the actual amount of memory that would be allocated.
// TODO: `sizeof(T)` doesn't work, so we resorted to `sizeof(long)`, but ideally we should get it to work somehow — see https://github.com/sqids/sqids-dotnet/pull/15#issue-1872663234
Span<char> shuffledAlphabet = options.Alphabet.Length * sizeof(long) > MaxStackallocSize // NOTE: We multiply the number of characters by the size of a `char` to get the actual amount of memory that would be allocated.
? new char[options.Alphabet.Length]
: stackalloc char[options.Alphabet.Length];
options.Alphabet.AsSpan().CopyTo(shuffledAlphabet);
Expand All @@ -76,7 +131,11 @@ public SqidsEncoder(SqidsOptions options)
/// <returns>A string containing the encoded ID.</returns>
/// <exception cref="T:System.ArgumentOutOfRangeException">If any of the integers passed is smaller than <see cref="MinValue"/> (i.e. negative) or greater than <see cref="MaxValue"/> (i.e. `int.MaxValue`).</exception>
/// <exception cref="T:System.OverflowException">If the decoded number overflows integer.</exception>
#if NET7_0_OR_GREATER
public string Encode(T number)
#else
public string Encode(int number)
#endif
{
if (number < MinValue || number > MaxValue)
throw new ArgumentOutOfRangeException($"Encoding supports numbers between '{MinValue}' and '{MaxValue}'.");
Expand All @@ -91,7 +150,11 @@ public string Encode(int number)
/// <returns>A string containing the encoded IDs, or an empty string if the array passed is empty.</returns>
/// <exception cref="T:System.ArgumentOutOfRangeException">If any of the integers passed is smaller than <see cref="MinValue"/> (i.e. negative) or greater than <see cref="MaxValue"/> (i.e. `int.MaxValue`).</exception>
/// <exception cref="T:System.OverflowException">If the decoded number overflows integer.</exception>
#if NET7_0_OR_GREATER
public string Encode(params T[] numbers)
#else
public string Encode(params int[] numbers)
#endif
{
if (numbers.Length == 0)
return string.Empty;
Expand All @@ -109,15 +172,28 @@ public string Encode(params int[] numbers)
/// <returns>A string containing the encoded IDs, or an empty string if the `IEnumerable` passed is empty.</returns>
/// <exception cref="T:System.ArgumentOutOfRangeException">If any of the integers passed is smaller than <see cref="MinValue"/> (i.e. negative) or greater than <see cref="MaxValue"/> (i.e. `int.MaxValue`).</exception>
/// <exception cref="T:System.OverflowException">If the decoded number overflows integer.</exception>
#if NET7_0_OR_GREATER
public string Encode(IEnumerable<T> numbers) =>
#else
public string Encode(IEnumerable<int> numbers) =>
#endif
Encode(numbers.ToArray());

// TODO: Consider using `ArrayPool` if possible
#if NET7_0_OR_GREATER
private string Encode(ReadOnlySpan<T> numbers, bool partitioned = false)
#else
private string Encode(ReadOnlySpan<int> numbers, bool partitioned = false)
#endif
{
int offset = 0;
for (int i = 0; i < numbers.Length; i++)
#if NET7_0_OR_GREATER
offset += _alphabet[int.CreateChecked(numbers[i] % T.CreateChecked(_alphabet.Length))] + i;
#else
offset += _alphabet[numbers[i] % _alphabet.Length] + i;
#endif

offset = (numbers.Length + offset) % _alphabet.Length;

Span<char> alphabetTemp = _alphabet.Length * sizeof(char) > MaxStackallocSize
Expand All @@ -136,8 +212,7 @@ private string Encode(ReadOnlySpan<int> numbers, bool partitioned = false)

for (int i = 0; i < numbers.Length; i++)
{
int number = numbers[i];

var number = numbers[i];
var alphabetWithoutSeparator = alphabetTemp[..^1];
var encodedNumber = ToId(number, alphabetWithoutSeparator);
builder.Append(encodedNumber);
Expand All @@ -162,10 +237,20 @@ private string Encode(ReadOnlySpan<int> numbers, bool partitioned = false)
{
if (!partitioned)
{
#if NET7_0_OR_GREATER
Span<T> newNumbers = (numbers.Length + 1) * sizeof(long) > MaxStackallocSize
? new T[numbers.Length + 1]
: stackalloc T[numbers.Length + 1];

newNumbers[0] = T.Zero;
#else
Span<int> newNumbers = (numbers.Length + 1) * sizeof(int) > MaxStackallocSize
? new int[numbers.Length + 1]
: stackalloc int[numbers.Length + 1];

newNumbers[0] = 0;
#endif

numbers.CopyTo(newNumbers[1..]);
result = Encode(newNumbers, partitioned: true);
}
Expand All @@ -181,24 +266,46 @@ private string Encode(ReadOnlySpan<int> numbers, bool partitioned = false)

if (IsBlockedId(result.AsSpan()))
{
#if NET7_0_OR_GREATER
Span<T> newNumbers = numbers.Length * sizeof(long) > MaxStackallocSize
? new T[numbers.Length]
: stackalloc T[numbers.Length];
#else
Span<int> newNumbers = numbers.Length * sizeof(int) > MaxStackallocSize
? new int[numbers.Length]
: stackalloc int[numbers.Length];
#endif
numbers.CopyTo(newNumbers);

if (partitioned)
{
#if NET7_0_OR_GREATER
if (numbers[0] + T.One > MaxValue)
throw new OverflowException("Ran out of range checking against the blocklist.");
else
newNumbers[0] += T.One;
#else
if (numbers[0] + 1 > MaxValue)
throw new OverflowException("Ran out of range checking against the blocklist.");
else
newNumbers[0] += 1;
#endif
}
else
{
#if NET7_0_OR_GREATER
newNumbers = (numbers.Length + 1) * sizeof(long) > MaxStackallocSize
? new T[numbers.Length + 1]
: stackalloc T[numbers.Length + 1];

newNumbers[0] = T.Zero;
#else
newNumbers = (numbers.Length + 1) * sizeof(int) > MaxStackallocSize
? new int[numbers.Length + 1]
: stackalloc int[numbers.Length + 1];

newNumbers[0] = 0;
#endif
numbers.CopyTo(newNumbers[1..]);
}

Expand All @@ -217,14 +324,26 @@ private string Encode(ReadOnlySpan<int> numbers, bool partitioned = false)
/// if the ID represents a single number); or an empty array if the input ID is null,
/// empty, or includes characters not found in the alphabet.
/// </returns>
#if NET7_0_OR_GREATER
public T[] Decode(ReadOnlySpan<char> id)
#else
public int[] Decode(ReadOnlySpan<char> id)
#endif
{
if (id.IsEmpty)
#if NET7_0_OR_GREATER
return Array.Empty<T>();
#else
return Array.Empty<int>();
#endif

foreach (char c in id)
if (!_alphabet.Contains(c))
#if NET7_0_OR_GREATER
return Array.Empty<T>();
#else
return Array.Empty<int>();
#endif

var alphabetSpan = _alphabet.AsSpan();

Expand All @@ -248,8 +367,11 @@ public int[] Decode(ReadOnlySpan<char> id)
ConsistentShuffle(alphabetTemp);
}

#if NET7_0_OR_GREATER
var result = new List<T>();
#else
var result = new List<int>();

#endif
while (!id.IsEmpty)
{
char separator = alphabetTemp[^1];
Expand All @@ -265,7 +387,11 @@ public int[] Decode(ReadOnlySpan<char> id)

foreach (char c in chunk)
if (!alphabetWithoutSeparator.Contains(c))
#if NET7_0_OR_GREATER
return Array.Empty<T>();
#else
return Array.Empty<int>();
#endif

var decodedNumber = ToNumber(chunk, alphabetWithoutSeparator);
result.Add(decodedNumber);
Expand Down Expand Up @@ -324,25 +450,47 @@ private static void ConsistentShuffle(Span<char> chars)
}
}

#if NET7_0_OR_GREATER
private static ReadOnlySpan<char> ToId(T num, ReadOnlySpan<char> alphabet)
#else
private static ReadOnlySpan<char> ToId(int num, ReadOnlySpan<char> alphabet)
#endif
{
var id = new StringBuilder();
int result = num;
var result = num;

#if NET7_0_OR_GREATER
do
{
id.Insert(0, alphabet[int.CreateChecked(result % T.CreateChecked(alphabet.Length))]);
result = result / T.CreateChecked(alphabet.Length);
} while (result > T.Zero);
#else
do
{
id.Insert(0, alphabet[result % alphabet.Length]);
result = result / alphabet.Length;
} while (result > 0);
#endif

return id.ToString().AsSpan(); // TODO: possibly avoid creating a string
}

#if NET7_0_OR_GREATER
private static T ToNumber(ReadOnlySpan<char> id, ReadOnlySpan<char> alphabet)
#else
private static int ToNumber(ReadOnlySpan<char> id, ReadOnlySpan<char> alphabet)
#endif
{
#if NET7_0_OR_GREATER
T result = T.Zero;
foreach (var character in id)
result = result * T.CreateChecked(alphabet.Length) + T.CreateChecked(alphabet.IndexOf(character));
#else
int result = 0;
foreach (var character in id)
result = result * alphabet.Length + alphabet.IndexOf(character);
#endif
return result;
}
}
8 changes: 8 additions & 0 deletions src/Sqids/SqidsOptions.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
namespace Sqids;

#if NET7_0_OR_GREATER
/// <summary>
/// The configuration options for <see cref="SqidsEncoder{T}" />.
/// All properties are optional; any property that isn't explicitly specified will fall back to its
/// default value.
/// </summary>
#else
/// <summary>
/// The configuration options for <see cref="SqidsEncoder" />.
/// All properties are optional; any property that isn't explicitly specified will fall back to its
/// default value.
/// </summary>
#endif
public sealed class SqidsOptions
{
/// <summary>
Expand Down
Loading

0 comments on commit 59812c2

Please sign in to comment.