Type-Agnostic Generic Algorithms
We looked at some generic algorithm examples in the previous article, but they weren’t very generic in one respect: they all required a NativeArray<T>
. What if we wanted to make them more generic so they could work on any type of collection? Today’s article shows two ways to do just that!
IIndexedCollection
Today we’ll use the Map
algorithm from last time as our example generic algorithm. Here’s how it looks with NativeArray<T>
required:
public static class Algorithms { public interface IMapper<TFrom, TTo> where TFrom : struct where TTo : struct { TTo Map(in TFrom item); } public static NativeArray<TTo> Map<TFrom, TTo, TMapper>( this NativeArray<TFrom> input, NativeArray<TTo> output, in TMapper mapper) where TFrom : struct where TTo : struct where TMapper : IMapper<TFrom, TTo> { for (int i = 0; i < input.Length; ++i) { output[i] = mapper.Map(input[i]); } return output; } }
The first way we’ll abstract NativeArray<T>
is to create an interface that describes the functionality Map
is actually using from NativeArray<T>
. It’s actually just one get
property and one get
and set
indexer:
public interface IIndexedCollection<T> { int Length { get; } T this[int index] { get; set; } }
Now we can change Map
to use IIndexedCollection<T>
instead of NativeArray<T>
. In doing so, we’ll need to add new TIndexedCollectionFrom
and TIndexedCollectionTo
type parameters with the appropriate constraints. The body of the function remains untouched!
public static TIndexedCollectionTo Map< TIndexedCollectionFrom, TIndexedCollectionTo, TFrom, TTo, TMapper>( this TIndexedCollectionFrom input, TIndexedCollectionTo output, in TMapper mapper) where TIndexedCollectionFrom : IIndexedCollection<TFrom> where TIndexedCollectionTo : IIndexedCollection<TTo> where TFrom : struct where TTo : struct where TMapper : IMapper<TFrom, TTo> { for (int i = 0; i < input.Length; ++i) { output[i] = mapper.Map(input[i]); } return output; }
To use this, we’ll need the IMapper<TFrom, TTo>
from last time:
struct SqrtMapper : Algorithms.IMapper<int, float> { public float Map(in int item) { return math.sqrt(item); } }
And we’ll need an IIndexedCollection<T>
that wraps NativeArray<T>
:
struct NativeArrayIndexedCollection<T> : Algorithms.IIndexedCollection<T> where T : struct { public NativeArray<T> Array; public NativeArrayIndexedCollection(NativeArray<T> array) { Array = array; } public static implicit operator NativeArrayIndexedCollection<T>( NativeArray<T> array) { return new NativeArrayIndexedCollection<T>(array); } public static implicit operator NativeArray<T>( NativeArrayIndexedCollection<T> collection) { return collection.Array; } public int Length => Array.Length; public T this[int index] { get => Array[index]; set => Array[index] = value; } }
This holds a NativeArray<T>
as its only field and implements the required property and indexer by simply calling the corresponding NativeArray<T>
functions. The constructor and type conversion functions are just there for convenience, as we’re about to see.
The final step is to actually call Map
:
NativeArray<int> input = new NativeArray<int>(3, Allocator.Temp); input[0] = 2; input[1] = 3; input[2] = 4; NativeArray<float> output = new NativeArray<float>(3, Allocator.Temp); new NativeArrayIndexedCollection<int>(input).Map< NativeArrayIndexedCollection<int>, NativeArrayIndexedCollection<float>, int, float, SqrtMapper>( output, new SqrtMapper()); for (int i = 0; i < output.Length; ++i) { Debug.Log(output[i]); // 1.414214, 1.732051, 2 }
First, we wrap input
into a NativeArrayIndexedCollection<int>
by calling the constructor. Second, we have to specify all the type parameters because the C# compiler can’t infer them. Third, we don’t need to call the constructor for output
because we defined an implicit
conversion function and the compiler will therefore call the conversion code for us.
This uses the extension method calling style, but we can just as easily call Map
explicitly`:
Algorithms.Map< NativeArrayIndexedCollection<int>, NativeArrayIndexedCollection<float>, int, float, SqrtMapper>( input, output, new SqrtMapper());
In this version, there’s no need to explicitly call the constructor for input
because the implicit
conversion function will do that for us. On the other hand, we need to spell out Algorithms.Map
instead of just Map
.
IForwardIterator
The first approach still requires a collection that can be indexed into. What if we wanted to go further and allow any collection that can be iterated through? That would be similar to how foreach
works with any type of collection and would allow other native collections to be used.
To relax these restrictions, we’ll use this interface instead of IIndexedCollection<T>
:
public interface IForwardIterator<T, TForwardIterator> : IEquatable<TForwardIterator> where TForwardIterator : IForwardIterator<T, TForwardIterator> { T Value { get; set; } void Next(); }
With an IForwardIterator<T, TForwardIterator>
, all we can do is get the current value instead of the value at any index. To move forward to subsequent values, we can call Next
. We can also know if two iterators are equal because we implemented IEquatable<TForwardIterator>
.
Rewriting Map
with this interface requires more changes than just swapping IIndexedCollection<T>
for IForwardIterator<T, TForwardIterator>
. First, we need to take two IForwardIterator<T, TForwardIterator>
parameters to specify the input. This is because we need to know where to start and where to stop because we can’t index at 0
and stop at Length - 1
now that we don’t have the ability to index or the Length
property.
Second, we need to rewrite the body of the function to do away with the index-based looping strategy. Instead, we’ll call Next
on inputStart
until it equals inputEnd
:
public static TForwardIteratorTo Map< TForwardIteratorFrom, TForwardIteratorTo, TFrom, TTo, TMapper>( this TForwardIteratorFrom inputStart, TForwardIteratorFrom inputEnd, TForwardIteratorTo output, in TMapper mapper) where TForwardIteratorFrom : IForwardIterator<TFrom, TForwardIteratorFrom> where TForwardIteratorTo : IForwardIterator<TTo, TForwardIteratorTo> where TFrom : struct where TTo : struct where TMapper : IMapper<TFrom, TTo> { for (; !inputStart.Equals(inputEnd); inputStart.Next(), output.Next()) { output.Value = mapper.Map(inputStart.Value); } return output; }
We can keep using SqrtMapper
, but we’ll need to define a NativeArrayIterator<T>
in order to call Map
:
struct NativeArrayIterator<T> : Algorithms.IForwardIterator<T, NativeArrayIterator<T>> where T : struct { public NativeArray<T> Array; public int Index; public NativeArrayIterator(NativeArray<T> array, int index) { Array = array; Index = index; } public bool Equals(NativeArrayIterator<T> other) => Array.Equals(other.Array) && Index == other.Index; public T Value { get => Array[Index]; set => Array[Index] = value; } public void Next() { Index++; } }
Again, we’re just wrapping the functionality of NativeArray<T>
into a struct that implements the required interface. One key difference is that the iterator must now contain the index into the array. This is because it’s not passed into the indexer anymore and the Next
function is espected to keep track of it for the next Value
get
call.
It’s also helpful to have a couple of extension methods to get iterators at the beginning and one-past-the-end of a NativeArray<T>
. These need to be in their own static class
due to C# language rules:
static class NativeArrayForwardIteratorExtensions { public static NativeArrayIterator<T> Begin<T>( this NativeArray<T> array) where T : struct { return new NativeArrayIterator<T>(array, 0); } public static NativeArrayIterator<T> End<T>( this NativeArray<T> array) where T : struct { return new NativeArrayIterator<T>(array, array.Length); } }
Finally, we can use these tools to call Map
:
NativeArray<int> input = new NativeArray<int>(3, Allocator.Temp); input[0] = 2; input[1] = 3; input[2] = 4; NativeArray<float> output = new NativeArray<float>(3, Allocator.Temp); input.Begin().Map< NativeArrayIterator<int>, NativeArrayIterator<float>, int, float, SqrtMapper>( input.End(), output.Begin(), new SqrtMapper()); for (int i = 0; i < output.Length; ++i) { Debug.Log(output[i]); // 1.414214, 1.732051, 2 }
We still have to pass all the type parameters because the C# compiler won’t infer their types. Again, we can explicitly call Map
rather than calling it as an extension method:
Algorithms.Map< NativeArrayIterator<int>, NativeArrayIterator<float>, int, float, SqrtMapper>( input.Begin(), input.End(), output.Begin(), new SqrtMapper());
Conclusion
Today we’ve seen two ways to make our generic algorithms more generic. We can abstract the collection type with another use of the “interface and type parameter” combination that enables Burst compilation. We can even abstract the fact that the collection is indexed by moving the indexing into an iterator type. The results are unfortunately marred by the requirement to pass all the type parameters, but aside from this verbosity the algorithm itself is now usable across a wide range of collection types.
Note: Burst 1.2.3 on Unity 2019.3.9f1 crashes with either of these approaches. Hopefully the bug will be fixed soon.
#1 by benjamin guihaire on April 27th, 2020 ·
what about Span ?
Span is not included in C# Unity, but its not hard to create that class (code is also available here https://referencesource.microsoft.com/#PresentationCore/Core/CSharp/MS/Internal/Span.cs)
… and then you could create extensions to return a Span from NativeArray, List , Array
#2 by jackson on April 28th, 2020 ·
I’ve tried using or creating
Span
in previous Unity versions and could never get it to work. I haven’t tried with 2019.3, but I’ll probably give it another shot.