-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathPooledString.cs
More file actions
477 lines (401 loc) · 15.5 KB
/
PooledString.cs
File metadata and controls
477 lines (401 loc) · 15.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
namespace LookBusy;
using System;
using System.Collections.Generic;
/* PooledString is a small immutable struct that represents a string allocated from an UnmanagedStringPool.
It holds a reference to the pool and an allocation ID, which together identify the actual string data in unmanaged memory.
Because it is a struct, it has value semantics - two PooledStrings with the same content are considered equal, even if they come from different pools.
PooledString provides methods to read the string as a ReadOnlySpan<char> for efficient access without additional allocations.
It also has methods to manipulate the string, such as Insert and Replace, which return new PooledString instances with the modified content.
These operations allocate new memory from the pool as needed.
Empty strings have special behavior: they use a reserved allocation ID (0) and don't require actual pool memory.
However, empty strings become invalid after pool disposal since expanding them would require the pool.
Operations like Insert on empty strings require a valid pool to allocate the resulting non-empty string.
PooledString implements IDisposable to allow freeing its memory back to the pool when no longer needed.
Double-freeing is safe - freeing an already freed PooledString has no effect.
Think of PooledString as similar to a ReadOnlyMemory<char> that is backed by unmanaged memory from a pool, with additional string manipulation capabilities.
*/
/// <summary>
/// Value type representing a string allocated from an unmanaged pool. Just a reference and an allocation ID, 12 bytes total.
/// </summary>
/// <remarks>
/// <para><b>Copy Behavior and Disposal:</b></para>
/// <para>
/// PooledString is a value type (struct) with reference semantics for the underlying memory.
/// When you copy a PooledString (e.g., via assignment), both the original and the copy share
/// the same allocation ID and point to the same memory in the pool.
/// </para>
/// <para>
/// <b>Important:</b> Disposing any copy invalidates ALL copies of that PooledString.
/// This is because disposal removes the allocation from the pool's internal tracking,
/// making the allocation ID invalid for all structs that reference it.
/// </para>
/// <example>
/// <code>
/// var original = pool.Allocate("Hello");
/// var copy = original; // Both share the same allocation
///
/// original.Dispose(); // Frees the allocation
/// // Now BOTH original and copy are invalid:
/// copy.AsSpan(); // Throws ArgumentException
/// original.AsSpan(); // Also throws ArgumentException
/// </code>
/// </example>
/// <para>
/// This behavior mirrors unmanaged memory semantics where freeing memory invalidates
/// all pointers to it. Multiple disposals are safe (idempotent) - calling Dispose()
/// on an already-freed PooledString has no effect.
/// </para>
/// <para>
/// <b>Warning - Memory Leaks:</b> Reassigning a PooledString variable without first
/// calling Dispose() will leak the original allocation. The original memory remains
/// allocated in the pool but becomes unreferenced and inaccessible.
/// </para>
/// <example>
/// <code>
/// var str = pool.Allocate("Original");
/// str = pool.Allocate("New"); // LEAK: "Original" is now unreferenced but still allocated
///
/// // Correct approach:
/// var str = pool.Allocate("Original");
/// str.Dispose(); // Free the original allocation first
/// str = pool.Allocate("New"); // Now safe to reassign
/// </code>
/// </example>
/// </remarks>
[System.Diagnostics.DebuggerDisplay("{ToString(),nq}")]
public readonly record struct PooledString(UnmanagedStringPool Pool, uint AllocationId) : IDisposable
{
// NOTE this struct is technically immutable, but some methods mutate the underlying pool like SetAtPosition() and Free()
// It also implements IDisposable to call Free() automatically
#region Public API
/// <summary>
/// Get this string as a span for efficient reading
/// </summary>
public readonly ReadOnlySpan<char> AsSpan()
{
if (AllocationId == UnmanagedStringPool.EmptyStringAllocationId) {
return []; // Empty string
}
CheckDisposed();
var info = Pool.GetAllocationInfo(AllocationId); // this will throw if the ID has been freed
unsafe {
return new((void*)info.Pointer, info.LengthChars);
}
}
/// <summary>
/// Free this string's memory back to the pool. This doesn't mutate the actual PooledString fields, it just updates the underlying pool
/// </summary>
/// <remarks>
/// <b>Warning:</b> This invalidates ALL copies of this PooledString, not just this instance.
/// Since PooledString is a value type, copies share the same allocation ID. Freeing any copy
/// removes the allocation from the pool, making all copies invalid.
/// Multiple calls to Free() on the same or different copies are safe (idempotent).
/// </remarks>
public readonly void Free() => Pool?.FreeString(AllocationId);
/// <summary>
/// Creates a deep copy of this PooledString with a new allocation ID.
/// </summary>
/// <returns>A new PooledString with the same content but a different allocation ID</returns>
/// <remarks>
/// Unlike simple assignment which creates copies that share the same allocation,
/// Duplicate() allocates new memory in the pool for an independent copy.
/// The duplicated string will not be affected if the original is disposed, and vice versa.
/// </remarks>
public readonly PooledString Duplicate()
{
if (AllocationId == UnmanagedStringPool.EmptyStringAllocationId) {
// Empty strings don't need actual cloning, just return the same empty reference
return this;
}
CheckDisposed();
// Allocate new memory in the pool with the same content
var span = AsSpan();
return Pool.Allocate(span);
}
/// <summary>
/// Allocate a new PooledString with the given value at the specified position. Old PooledString is unchanged.
/// </summary>
public readonly PooledString Insert(int pos, ReadOnlySpan<char> value)
{
if (value.IsEmpty) {
return this;
}
if (AllocationId == UnmanagedStringPool.EmptyStringAllocationId && pos != 0) {
throw new ArgumentOutOfRangeException(nameof(pos), "Cannot insert into an empty string at position other than 0");
}
CheckDisposed();
var currentSpan = AsSpan();
if (pos < 0 || pos > currentSpan.Length) {
throw new ArgumentOutOfRangeException(nameof(pos), "Position is out of bounds");
}
// Allocate a new string of the required total size
var result = Pool.Allocate(currentSpan.Length + value.Length);
// Copy the three parts directly into the new buffer
var beforeInsert = currentSpan[..pos];
var afterInsert = currentSpan[pos..];
// First part: characters before the insertion point
if (beforeInsert.Length > 0) {
result.SetAtPosition(0, beforeInsert);
}
// Middle part: the value to insert
result.SetAtPosition(pos, value);
// Last part: characters after the insertion point
if (afterInsert.Length > 0) {
result.SetAtPosition(pos + value.Length, afterInsert);
}
return result;
}
/// <summary>
/// Returns the zero-based index of the first occurrence of the specified string
/// </summary>
public readonly int IndexOf(ReadOnlySpan<char> value, StringComparison comparison = StringComparison.Ordinal)
{
if (AllocationId == UnmanagedStringPool.EmptyStringAllocationId) {
return value.IsEmpty ? 0 : -1;
}
return AsSpan().IndexOf(value, comparison);
}
/// <summary>
/// Returns the zero-based index of the last occurrence of the specified string
/// </summary>
public readonly int LastIndexOf(ReadOnlySpan<char> value, StringComparison comparison = StringComparison.Ordinal)
{
if (AllocationId == UnmanagedStringPool.EmptyStringAllocationId) {
return value.IsEmpty ? 0 : -1;
}
return AsSpan().LastIndexOf(value, comparison);
}
/// <summary>
/// Determines whether this string starts with the specified string
/// </summary>
public readonly bool StartsWith(ReadOnlySpan<char> value, StringComparison comparison = StringComparison.Ordinal)
{
if (AllocationId == UnmanagedStringPool.EmptyStringAllocationId) {
return value.IsEmpty;
}
return AsSpan().StartsWith(value, comparison);
}
/// <summary>
/// Determines whether this string ends with the specified string
/// </summary>
public readonly bool EndsWith(ReadOnlySpan<char> value, StringComparison comparison = StringComparison.Ordinal)
{
if (AllocationId == UnmanagedStringPool.EmptyStringAllocationId) {
return value.IsEmpty;
}
return AsSpan().EndsWith(value, comparison);
}
/// <summary>
/// Determines whether this string contains the specified string
/// </summary>
public readonly bool Contains(ReadOnlySpan<char> value, StringComparison comparison = StringComparison.Ordinal)
{
if (AllocationId == UnmanagedStringPool.EmptyStringAllocationId) {
return value.IsEmpty;
}
return AsSpan().Contains(value, comparison);
}
/// <summary>
/// Gets the length of this string in characters
/// </summary>
public readonly int Length
{
get
{
if (AllocationId == UnmanagedStringPool.EmptyStringAllocationId) {
return 0;
}
CheckDisposed();
var info = Pool.GetAllocationInfo(AllocationId);
return info.LengthChars;
}
}
/// <summary>
/// Determines whether this string is empty
/// </summary>
public readonly bool IsEmpty => AllocationId == UnmanagedStringPool.EmptyStringAllocationId || Length == 0;
/// <summary>
/// Extract a substring from this string, just a convenience method for AsSpan().Slice()
/// </summary>
public readonly ReadOnlySpan<char> SubstringSpan(int startIndex, int length)
{
var span = AsSpan();
if (startIndex < 0 || startIndex > span.Length) {
throw new ArgumentOutOfRangeException(nameof(startIndex), $"Start index {startIndex} is out of range for string of length {span.Length}");
}
if (length < 0 || startIndex + length > span.Length) {
throw new ArgumentOutOfRangeException(nameof(length),
$"Length {length} from start index {startIndex} exceeds string length {span.Length}");
}
return span.Slice(startIndex, length);
}
/// <summary>
/// Replace all occurrences of a substring with another string
/// </summary>
public readonly PooledString Replace(ReadOnlySpan<char> oldValue, ReadOnlySpan<char> newValue)
{
CheckDisposed();
if (oldValue.IsEmpty) {
return this;
}
var span = AsSpan();
if (span.Length == 0) {
return this;
}
// Single pass: find occurrences and track positions
var occurrences = new List<int>();
var pos = 0;
while (pos < span.Length) {
var foundIndex = span[pos..].IndexOf(oldValue);
if (foundIndex < 0) {
break;
}
occurrences.Add(pos + foundIndex);
pos += foundIndex + oldValue.Length;
}
if (occurrences.Count == 0) {
return this; // Nothing to replace
}
// Calculate new size and check for overflow
var sizeDiff = newValue.Length - oldValue.Length;
if (sizeDiff > 0 && occurrences.Count > 0) {
// Check if the total increase would cause overflow
// We want to avoid: span.Length + sizeDiff * occurrences.Count > int.MaxValue
// Rearranged: sizeDiff * occurrences.Count > int.MaxValue - span.Length
if (occurrences.Count > (int.MaxValue - span.Length) / sizeDiff) {
throw new ArgumentException("Replacement would result in string too large");
}
}
var newSize = span.Length + (sizeDiff * occurrences.Count);
if (newSize < 0) {
throw new ArgumentException("Replacement would result in invalid size");
}
var result = Pool.Allocate(newSize);
// Perform replacements in a single pass using tracked positions
var srcPos = 0;
var destPos = 0;
foreach (var occurrencePos in occurrences) {
// Copy text before match
var before = span[srcPos..occurrencePos];
if (before.Length > 0) {
result.SetAtPosition(destPos, before);
destPos += before.Length;
}
// Copy replacement
if (newValue.Length > 0) {
result.SetAtPosition(destPos, newValue);
destPos += newValue.Length;
}
// Update source position
srcPos = occurrencePos + oldValue.Length;
}
// Copy remaining text
if (srcPos < span.Length) {
result.SetAtPosition(destPos, span[srcPos..]);
}
return result;
}
/// <summary>
/// Value semantics comparison. Note this is a record struct, so == and != are already implemented
/// We override Equals to compare content rather than pool and allocation ID
/// </summary>
public readonly bool Equals(PooledString other)
{
#pragma warning disable IDE0046 // Convert to conditional expression
if (AllocationId == UnmanagedStringPool.EmptyStringAllocationId &&
other.AllocationId == UnmanagedStringPool.EmptyStringAllocationId) {
return true;
}
#pragma warning restore IDE0046 // Convert to conditional expression
// Check for null or disposed pools before attempting to get spans
if (Pool == null || Pool.IsDisposed || other.Pool == null || other.Pool.IsDisposed) {
return false;
}
// compare as spans
return AsSpan().Equals(other.AsSpan(), StringComparison.Ordinal);
}
/// <summary>
/// Convert to standard .NET string (allocates managed memory)
/// </summary>
public override readonly string ToString() => AsSpan().ToString();
/// <summary>
/// Hash code based on content
/// </summary>
public override readonly int GetHashCode()
{
if (AllocationId == UnmanagedStringPool.EmptyStringAllocationId) {
return 0;
}
if (Pool.IsDisposed) {
return -1;
}
const int maxChars = 64;
const int halfMax = maxChars / 2;
var span = AsSpan();
var hash = new HashCode();
if (span.Length <= maxChars) {
// Hash all characters using for loop to avoid allocation
foreach (var t in span) {
hash.Add(t);
}
} else {
// Hash first fragment chars
for (var i = 0; i < halfMax; ++i) {
hash.Add(span[i]);
}
// Hash last fragment chars
var startIndex = span.Length - halfMax;
for (var i = startIndex; i < span.Length; ++i) {
hash.Add(span[i]);
}
}
return hash.ToHashCode();
}
#endregion // public API
/// <summary>
/// Checks if the underlying pool is disposed before performing any operations
/// </summary>
private readonly void CheckDisposed()
{
if (Pool.IsDisposed) {
throw new ObjectDisposedException(nameof(PooledString));
}
}
/// <summary>
/// Internal mutate method to set part of the buffer. Note this doesn't actually mutate the PooledString itself, just the underlying pool.
/// </summary>
private readonly void SetAtPosition(int start, ReadOnlySpan<char> value)
{
CheckDisposed();
if (AllocationId == UnmanagedStringPool.EmptyStringAllocationId) {
throw new InvalidOperationException("Cannot mutate an empty string allocation");
}
// Get the current allocation info
var info = Pool.GetAllocationInfo(AllocationId);
// Check if the starting position is valid
if (start < 0) {
throw new ArgumentOutOfRangeException(nameof(start), "Start position cannot be negative");
}
// Check if the value will fit in the buffer
if (start + value.Length > info.LengthChars) {
throw new ArgumentOutOfRangeException(
nameof(value),
$"The provided value is too large to fit in the string at the specified position. Available space: {info.LengthChars - start}, required: {value.Length}");
}
// Copy the value to the target position
unsafe {
fixed (char* pChar = value) {
var dest = (void*)IntPtr.Add(info.Pointer, start * sizeof(char));
Buffer.MemoryCopy(pChar, dest, (info.LengthChars - start) * sizeof(char), value.Length * sizeof(char));
}
}
}
/// <summary>
/// Free the string back to the pool, if it is not empty
/// </summary>
/// <remarks>
/// <b>Warning:</b> This invalidates ALL copies of this PooledString, not just this instance.
/// See <see cref="Free"/> for details about copy behavior.
/// </remarks>
public void Dispose() => Free();
}