Skip to content

Commit 72d6847

Browse files
author
msftbot[bot]
authored
Added [Memory|Span]Owner<T>.DangerousGetArray (#3530)
## PR Type What kind of change does this PR introduce? - Feature <!-- - Code style update (formatting) --> <!-- - Refactoring (no functional changes, no api changes) --> <!-- - Build or CI related changes --> <!-- - Documentation content changes --> <!-- - Sample app changes --> <!-- - Other... Please describe: --> ## What is the current behavior? <!-- Please describe the current behavior that you are modifying, or link to a relevant issue. --> There is currently no way to get the underlying `T[]` array from a `MemoryOwner<T>` or `SpanOwner<T>` instance without going through some hoops that are very inconvenient (and which are only possible for `MemoryOwner<T>`). Being able to use the array directly is necessary when working with some older APIs that don't offer a `Span<T>` or `Memory<T>` overload. ## What is the new behavior? <!-- Describe how was this issue resolved or changed? --> This PR introduces a new `DangerousGetArray` method that mirrors the `MemoryMarshal.TryGetArray` method and works on `MemoryOwner<T>` and `SpanOwner<T>` instances. I've removed the try pattern since here the types guarantee that the underlying memory store will always be an array. The methods are called `Dangerous___` because using the array is potentially dangerous in case a user keeps the array after disposing the original owner, as it means that that array might've been rented to some other consumer, so using it could lead to unexpected behavior. The methods are not inherently dangerous per se. ## API surface ```csharp namespace Microsoft.Toolkit.HighPerformance.Buffers { public sealed class MemoryOwner<T> { public ArraySegment<T> DangerousGetArray(); } public readonly ref struct SpanOwner<T> { public ArraySegment<T> DangerousGetArray(); } } ``` ## Example usage Suppose we have a `Person` class with `string Name`, `string Surname` and `int Age` properties, and we want to calculate an MD5 hash with the current state of the class. This was originally asked by a user in the C# Discord server ([here](https://discordapp.com/channels/143867839282020352/312132327348240384/766694351383560205)). ```csharp public static string GetMD5Hash(Person person) { using var buffer = new ArrayPoolBufferWriter<byte>(); buffer.Write<char>(person.Name); buffer.Write<char>(person.Surname); buffer.Write(person.Age); using SpanOwner<byte> hash = SpanOwner<byte>.Allocate(16); using var md5 = MD5.Create(); md5.TryComputeHash(buffer.WrittenSpan, hash.Span, out _); return BitConverter.ToString(hash.DangerousGetArray().Array!, 0, 16); } ``` You can see how here we can leverage the new `DangerousGetArray` API to get the underlying array to use with the `BitConverter.ToString` API, which doesn't have an overload accepting a `ReadOnlySpan<byte>`. The same goes for many other existing APIs that only accept an array as input data instead of the new memory APIs. ## PR Checklist Please check if your PR fulfills the following requirements: - [X] Tested code with current [supported SDKs](../readme.md#supported) - [ ] ~~Pull Request has been submitted to the documentation repository [instructions](..\contributing.md#docs). Link: <!-- docs PR link -->~~ - [ ] ~~Sample in sample app has been added / updated (for bug fixes / features)~~ - [ ] ~~Icon has been created (if new sample) following the [Thumbnail Style Guide and templates](https://github.com/windows-toolkit/WindowsCommunityToolkit-design-assets)~~ - [X] Tests for the changes have been added (for bug fixes / features) (if applicable) - [X] Header has been added to all new source files (run *build/UpdateHeaders.bat*) - [X] Contains **NO** breaking changes
2 parents d434f43 + e356c35 commit 72d6847

File tree

4 files changed

+132
-5
lines changed

4 files changed

+132
-5
lines changed

Microsoft.Toolkit.HighPerformance/Buffers/MemoryOwner{T}.cs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
using System.Diagnostics;
88
using System.Diagnostics.Contracts;
99
using System.Runtime.CompilerServices;
10+
#if NETCORE_RUNTIME
11+
using System.Runtime.InteropServices;
12+
#endif
1013
using Microsoft.Toolkit.HighPerformance.Buffers.Views;
1114
using Microsoft.Toolkit.HighPerformance.Extensions;
1215

@@ -180,7 +183,22 @@ public Span<T> Span
180183
ThrowObjectDisposedException();
181184
}
182185

186+
#if NETCORE_RUNTIME
187+
ref T r0 = ref array!.DangerousGetReferenceAt(this.start);
188+
189+
// On .NET Core runtimes, we can manually create a span from the starting reference to
190+
// skip the argument validations, which include an explicit null check, covariance check
191+
// for the array and the actual validation for the starting offset and target length. We
192+
// only do this on .NET Core as we can leverage the runtime-specific array layout to get
193+
// a fast access to the initial element, which makes this trick worth it. Otherwise, on
194+
// runtimes where we would need to at least access a static field to retrieve the base
195+
// byte offset within an SZ array object, we can get better performance by just using the
196+
// default Span<T> constructor and paying the cost of the extra conditional branches,
197+
// especially if T is a value type, in which case the covariance check is JIT removed.
198+
return MemoryMarshal.CreateSpan(ref r0, this.length);
199+
#else
183200
return new Span<T>(array!, this.start, this.length);
201+
#endif
184202
}
185203
}
186204

@@ -208,6 +226,31 @@ public ref T DangerousGetReference()
208226
return ref array!.DangerousGetReferenceAt(this.start);
209227
}
210228

229+
/// <summary>
230+
/// Gets an <see cref="ArraySegment{T}"/> instance wrapping the underlying <typeparamref name="T"/> array in use.
231+
/// </summary>
232+
/// <returns>An <see cref="ArraySegment{T}"/> instance wrapping the underlying <typeparamref name="T"/> array in use.</returns>
233+
/// <exception cref="ObjectDisposedException">Thrown when the buffer in use has already been disposed.</exception>
234+
/// <remarks>
235+
/// This method is meant to be used when working with APIs that only accept an array as input, and should be used with caution.
236+
/// In particular, the returned array is rented from an array pool, and it is responsibility of the caller to ensure that it's
237+
/// not used after the current <see cref="MemoryOwner{T}"/> instance is disposed. Doing so is considered undefined behavior,
238+
/// as the same array might be in use within another <see cref="MemoryOwner{T}"/> instance.
239+
/// </remarks>
240+
[Pure]
241+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
242+
public ArraySegment<T> DangerousGetArray()
243+
{
244+
T[]? array = this.array;
245+
246+
if (array is null)
247+
{
248+
ThrowObjectDisposedException();
249+
}
250+
251+
return new ArraySegment<T>(array!, this.start, this.length);
252+
}
253+
211254
/// <summary>
212255
/// Slices the buffer currently in use and returns a new <see cref="MemoryOwner{T}"/> instance.
213256
/// </summary>
@@ -222,7 +265,6 @@ public ref T DangerousGetReference()
222265
/// size and copy the previous items into the new one, or needing an additional variable/field
223266
/// to manually handle to track the used range within a given <see cref="MemoryOwner{T}"/> instance.
224267
/// </remarks>
225-
[Pure]
226268
public MemoryOwner<T> Slice(int start, int length)
227269
{
228270
T[]? array = this.array;

Microsoft.Toolkit.HighPerformance/Buffers/SpanOwner{T}.cs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
using System.Diagnostics;
88
using System.Diagnostics.Contracts;
99
using System.Runtime.CompilerServices;
10+
#if NETCORE_RUNTIME
11+
using System.Runtime.InteropServices;
12+
#endif
1013
using Microsoft.Toolkit.HighPerformance.Buffers.Views;
1114
using Microsoft.Toolkit.HighPerformance.Extensions;
1215

@@ -143,7 +146,16 @@ public int Length
143146
public Span<T> Span
144147
{
145148
[MethodImpl(MethodImplOptions.AggressiveInlining)]
146-
get => new Span<T>(this.array, 0, this.length);
149+
get
150+
{
151+
#if NETCORE_RUNTIME
152+
ref T r0 = ref array!.DangerousGetReference();
153+
154+
return MemoryMarshal.CreateSpan(ref r0, this.length);
155+
#else
156+
return new Span<T>(this.array, 0, this.length);
157+
#endif
158+
}
147159
}
148160

149161
/// <summary>
@@ -157,6 +169,23 @@ public ref T DangerousGetReference()
157169
return ref this.array.DangerousGetReference();
158170
}
159171

172+
/// <summary>
173+
/// Gets an <see cref="ArraySegment{T}"/> instance wrapping the underlying <typeparamref name="T"/> array in use.
174+
/// </summary>
175+
/// <returns>An <see cref="ArraySegment{T}"/> instance wrapping the underlying <typeparamref name="T"/> array in use.</returns>
176+
/// <remarks>
177+
/// This method is meant to be used when working with APIs that only accept an array as input, and should be used with caution.
178+
/// In particular, the returned array is rented from an array pool, and it is responsibility of the caller to ensure that it's
179+
/// not used after the current <see cref="SpanOwner{T}"/> instance is disposed. Doing so is considered undefined behavior,
180+
/// as the same array might be in use within another <see cref="SpanOwner{T}"/> instance.
181+
/// </remarks>
182+
[Pure]
183+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
184+
public ArraySegment<T> DangerousGetArray()
185+
{
186+
return new ArraySegment<T>(array!, 0, this.length);
187+
}
188+
160189
/// <summary>
161190
/// Implements the duck-typed <see cref="IDisposable.Dispose"/> method.
162191
/// </summary>

UnitTests/UnitTests.HighPerformance.Shared/Buffers/Test_MemoryOwner{T}.cs

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
// See the LICENSE file in the project root for more information.
44

55
using System;
6-
using System.Buffers;
76
using System.Diagnostics.CodeAnalysis;
87
using System.Linq;
98
using Microsoft.Toolkit.HighPerformance.Buffers;
@@ -105,7 +104,7 @@ public void Test_MemoryOwnerOfT_MultipleDispose()
105104
// by accident doesn't cause issues, and just does nothing.
106105
}
107106

108-
[TestCategory("HashCodeOfT")]
107+
[TestCategory("MemoryOwnerOfT")]
109108
[TestMethod]
110109
public void Test_MemoryOwnerOfT_PooledBuffersAndClear()
111110
{
@@ -124,5 +123,44 @@ public void Test_MemoryOwnerOfT_PooledBuffersAndClear()
124123
Assert.IsTrue(buffer.Span.ToArray().All(i => i == 0));
125124
}
126125
}
126+
127+
[TestCategory("MemoryOwnerOfT")]
128+
[TestMethod]
129+
public void Test_MemoryOwnerOfT_AllocateAndGetArray()
130+
{
131+
var buffer = MemoryOwner<int>.Allocate(127);
132+
133+
// Here we allocate a MemoryOwner<T> instance with a requested size of 127, which means it
134+
// internally requests an array of size 127 from ArrayPool<T>.Shared. We then get the array
135+
// segment, so we need to verify that (since buffer is not disposed) the returned array is
136+
// not null, is of size >= the requested one (since ArrayPool<T> by definition returns an
137+
// array that is at least of the requested size), and that the offset and count properties
138+
// match our input values (same length, and offset at 0 since the buffer was not sliced).
139+
var segment = buffer.DangerousGetArray();
140+
141+
Assert.IsNotNull(segment.Array);
142+
Assert.IsTrue(segment.Array.Length >= buffer.Length);
143+
Assert.AreEqual(segment.Offset, 0);
144+
Assert.AreEqual(segment.Count, buffer.Length);
145+
146+
var second = buffer.Slice(10, 80);
147+
148+
// The original buffer instance is disposed here, because calling Slice transfers
149+
// the ownership of the internal buffer to the new instance (this is documented in
150+
// XML docs for the MemoryOwner<T>.Slice method).
151+
Assert.ThrowsException<ObjectDisposedException>(() => buffer.DangerousGetArray());
152+
153+
segment = second.DangerousGetArray();
154+
155+
// Same as before, but we now also verify the initial offset != 0, as we used Slice
156+
Assert.IsNotNull(segment.Array);
157+
Assert.IsTrue(segment.Array.Length >= second.Length);
158+
Assert.AreEqual(segment.Offset, 10);
159+
Assert.AreEqual(segment.Count, second.Length);
160+
161+
second.Dispose();
162+
163+
Assert.ThrowsException<ObjectDisposedException>(() => second.DangerousGetArray());
164+
}
127165
}
128166
}

UnitTests/UnitTests.HighPerformance.Shared/Buffers/Test_SpanOwner{T}.cs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ public void Test_SpanOwnerOfT_InvalidRequestedSize()
6060
Assert.Fail("You shouldn't be here");
6161
}
6262

63-
[TestCategory("HashCodeOfT")]
63+
[TestCategory("SpanOwnerOfT")]
6464
[TestMethod]
6565
public void Test_SpanOwnerOfT_PooledBuffersAndClear()
6666
{
@@ -79,5 +79,23 @@ public void Test_SpanOwnerOfT_PooledBuffersAndClear()
7979
Assert.IsTrue(buffer.Span.ToArray().All(i => i == 0));
8080
}
8181
}
82+
83+
[TestCategory("SpanOwnerOfT")]
84+
[TestMethod]
85+
public void Test_SpanOwnerOfT_AllocateAndGetArray()
86+
{
87+
using var buffer = SpanOwner<int>.Allocate(127);
88+
89+
var segment = buffer.DangerousGetArray();
90+
91+
// See comments in the MemoryOwner<T> tests about this. The main difference
92+
// here is that we don't do the disposed checks, as SpanOwner<T> is optimized
93+
// with the assumption that usages after dispose are undefined behavior. This
94+
// is all documented in the XML docs for the SpanOwner<T> type.
95+
Assert.IsNotNull(segment.Array);
96+
Assert.IsTrue(segment.Array.Length >= buffer.Length);
97+
Assert.AreEqual(segment.Offset, 0);
98+
Assert.AreEqual(segment.Count, buffer.Length);
99+
}
82100
}
83101
}

0 commit comments

Comments
 (0)