Add initial draft of ownership for C# - #10296
Conversation
| var array = OwnedImmutableBuilder.ToImmutable(builder); | ||
| ``` | ||
|
|
||
| This looks very similar to how we use the rented array, with one notable exception: the static method `ToImmutable`. The main difference here is between instance and static methods on resource types. Static methods, or any method except the receiver of an instance method, will consider parameters of a resource type to be <u>owning references</u>, not borrowing references. Functionally, this is what allows us to guarantee that the array is transferred safely — it is never exposed by the builder, the builder is a resource so it is never copied, and the `ToImmutable` function takes ownership of the builder, meaning no references could live past the transfer. |
There was a problem hiding this comment.
This is the only part I don't like. It's awkward compared to an instance method. I think we should be able to add an attribute to an instance method that declares it takes its own ownership, like how an extension method would take ownership.
There was a problem hiding this comment.
I proposed BorrowedReceiver to allow adapting to borrows in classes. It would make sense to allow inverting that for owned types
|
Also how will a collection of resources be handled? |
|
|
||
| These rules seem simple but are very difficult to follow in practice. The core problem is _aliasing_. In real programs, variable reference doesn't follow a simple linear path through the method. Pointers are not assigned to a single variable — numerous copies are made and dereferenced both inside the method and inside the various helper methods that are called by both the original function and the helper functions themselves. At each point confusion can arise. It can be unclear whether the callee function or caller function is meant to free the variable. And it can be equally confusing to determine when all aliased uses are complete and the variable can be freed. Worse, the aliases are not invalidated after free, so it is easy for functions to mistakenly believe their alias is still valid. | ||
|
|
||
| The main innovation on this front came from C++ in the form of RAII. Put simply, RAII is a system where variables are automatically, and transitively, freed at the end of variable scope. The most important thing is that it ties the variable's memory to its lexical scope. This cleanly removes confusion about where and when a variable is freed — it is freed by the allocating `unique_ptr`, and it is freed at the end of lexical scope. `unique_ptr` makes it possible to call all methods directly, as though accessing a standard pointer. However, it prohibits copying, so new `unique_ptr` aliases cannot be created. C++ references (`&`) and raw pointers may still be created for temporary access in helper functions. |
There was a problem hiding this comment.
There is something missing in this section around transferring ownership via move semantics. I don't know where yet, but that notion seems missing after getting to the ImmutableArray discussion below.
| - The result of a constructor of `R` is an owning reference. | ||
| - At any given point in the program, only one owning reference to a value is permitted. Therefore, | ||
| - Copying resource variables is not permitted. All implicit struct copy behavior is disabled for resource types. | ||
| - Resource variables may only appear as fields of resource types. |
There was a problem hiding this comment.
I assume it cannot be a global static.
There was a problem hiding this comment.
Correct -- this was meant as a restriction on fields of non resource types. But also, they cannot be stored in static fields
|
|
||
| - `Drop()` is compiler-invoked and cannot be called directly. | ||
| - `Drop` is invoked when leaving the variable's lexical declaring scope | ||
| - This includes exception unwinding |
There was a problem hiding this comment.
This would imply that every method with one of these types now must contain a try block, correct? Additionally, it means there could now be many try blocks.
| - `Drop` is invoked when leaving the variable's lexical declaring scope | ||
| - This includes exception unwinding | ||
| - When an owning variable is re-assigned, `Drop` is called on the overwritten value | ||
| - Drop implementations should not throw |
There was a problem hiding this comment.
"should" or "must"? This seems important for the previous case note above where a method can have multiple try blocks. If this is "should" it then means there could be no optimization for collapsing try blocks for the same scope and each one of these variables would require its own try block.
There was a problem hiding this comment.
i'm actually not sure what breaks in all cases. it may be possible to recover from an exception with only moderately negative consquences. Definitely needs more thought
There was a problem hiding this comment.
I’m fine with making this “must” and specifying that an uncaught exception in Drop aborts the process
|
|
||
| Instead, helper functions should use borrowed references. These are references that explicitly do not take ownership of the resource. However, they are guaranteed to live no longer than the resource. Notably, borrowed references do not transfer when ownership transfers. Or, put another way, all borrow lifetimes must end before ownership is transferred. | ||
|
|
||
| Lastly, it is important to note that borrowed references are truly references to the owned value, not copies. Operations on borrowed references must occur on the same value as the owned value. This has some different implications for classes and structs. For structs it implies that borrowed references can never be "normal" struct values — borrowed struct references are always true managed by-ref variables. |
There was a problem hiding this comment.
For structs it implies that borrowed references can never be "normal" struct values — borrowed struct references are always true managed by-ref variables.
I assume this would limit use of these types to non-async methods. At least async v1.
There was a problem hiding this comment.
Correct, this is one of the main reasons to allow owned reference types -- by-refs are too limiting for many scenarios.
|
|
||
| Also note that the Borrow types are intrinsic -- they can violate some other rules, like substitution of resource types for generics. It is also illegal to copy `Borrow<T>`, as this would create multiple mutable references. In addition, the owner cannot be used, moved, or dropped while borrowed. | ||
|
|
||
| The `Value` property will be illegal to access by all code except the compiler. Note that all instance methods of resource types consider their receiver borrowed, so this includes all instance members. In fact, the compiler is responsible for analyzing all operations on `Borrow<T>` as if they were operations on `T` and automatically translating them through calls to `Value`. |
There was a problem hiding this comment.
Similar to the Drop rules above, I assume that using reflection here has some implication worth discussing
.
|
Love this. While not strictly lang related and likely not v1, I could see this allowing the runtime to intrinsify IResource and Drop(), to immediately/more efficiently dealloc reference types, outside of GC |
|
What about deferred ownership? This is quite common in ECS/async programming. For example, var buffer = pool.Rent(...);
Task A: ReadWrite(buffer);
Task B: ReadWrite(buffer); // depends on AIn Rust we can have let mut buffer = vec![0; 1024];
let a = tokio::spawn(async move {
process_a(&mut buffer).await;
buffer // ownership comes out of A
});
let b = tokio::spawn(async move {
// B can be scheduled immediately, but cannot access buffer yet.
let mut buffer = a.await.unwrap();
process_b(&mut buffer).await;
buffer
});
let buffer = b.await.unwrap();where in C# it's simliar to something like var buffer = pool.Rent(...);
var a = Schedule(buffer, static buffer => { ProcessA(buffer); return buffer; });
var b = Then(a, static buffer => { ProcessB(buffer); return buffer; });
var buffer = await b;Note that B can be created/scheduled before A has completed, but B does not actually receive a borrow of With the current rule that resource types cannot be substituted for generic type parameters, it would seem to prevent representations such as |
| * Arrays do not carry aliasing restrictions, meaning that multiple aliases to the parent array could appear, violating unique ownership | ||
| * Arrays would not call Drop on the elements | ||
|
|
||
| All of these problems are solved using a new language construct: _conditional implementation_. In existing C#, all types either implement an interface or not. This proposal would add a new special case, just for arrays: for a substituted type `T[]`, `T[]` implements the `IResource` interface if and only if `T` implements `IResource`. Correspondly, `T[]` is a resource if and only if `T` is a resource. The implementation of `Drop` is also specified as walking the array from beginning to end and calling `Drop` on each element. |
There was a problem hiding this comment.
| All of these problems are solved using a new language construct: _conditional implementation_. In existing C#, all types either implement an interface or not. This proposal would add a new special case, just for arrays: for a substituted type `T[]`, `T[]` implements the `IResource` interface if and only if `T` implements `IResource`. Correspondly, `T[]` is a resource if and only if `T` is a resource. The implementation of `Drop` is also specified as walking the array from beginning to end and calling `Drop` on each element. | |
| All of these problems are solved using a new language construct: _conditional implementation_. In existing C#, all types either implement an interface or not. This proposal would add a new special case, just for arrays: for a substituted type `T[]`, `T[]` implements the `IResource` interface if and only if `T` implements `IResource`. Correspondingly, `T[]` is a resource if and only if `T` is a resource. The implementation of `Drop` is also specified as walking the array from beginning to end and calling `Drop` on each element. |
|
|
||
| Like all borrows, it would be illegal to convert a borrowed reference to a GC reference. Thus, `M1` is restricted from escaping the receiver outside the current method. This cleanly allows arbitrary class types to appear as borrowed types. `BorrowedReceiver` is also legal on constructors -- because constructors can access `this` they have the same risk of exposing it outside the constructor method, unless it is typed as a borrow. | ||
|
|
||
| However, converting just GC types to borrows is not particularly useful on its own. We would like to be able to treat a class as either GC-tracked or owned, _per instantiation_. To do so we will need another instrinsic type: |
There was a problem hiding this comment.
| However, converting just GC types to borrows is not particularly useful on its own. We would like to be able to treat a class as either GC-tracked or owned, _per instantiation_. To do so we will need another instrinsic type: | |
| However, converting just GC types to borrows is not particularly useful on its own. We would like to be able to treat a class as either GC-tracked or owned, _per instantiation_. To do so we will need another intrinsic type: |
|
|
||
| public static ImmutableArray<T> ToImmutable(OwnedImmutableBuilder builder) | ||
| { | ||
| return ImmutableArray.UnsafeCreate( // Assume an internal unsafe method |
There was a problem hiding this comment.
There is already ImmutableCollectionsMarshal.AsImmutableArray
| For finite native resources, `File` is a good example. We can reliably close the file inside Drop, preventing leaking file handles or duplicate close. Note that for a real File, we may have buffered I/O that needs to be written -- because Drop is not `async` we cannot guarantee that all data is written, just that the file is closed. | ||
|
|
||
| ```csharp | ||
| class File : IResource |
There was a problem hiding this comment.
I wonder how BCL will migrate existing types like File. Making them into resources would be breaking. Perhaps we need some opt in flag to allow migration (like nullable or unsafe evolution).
There was a problem hiding this comment.
Flags only work when your code has no behavioral changes -- this does. Adding IResource will be a severe breaking change to an existing type. It will likely require a new API in all cases.
| - If the variable may be assigned before another assignment, assigning it ends its lifetime | ||
| - After transfer, use of the original reference is disallowed. | ||
| - There may be only one mutable reference, or any number of read-only references (aliasing rules). | ||
| - `R` may not be substituted for generic type parameters |
There was a problem hiding this comment.
So I can't have List<R> nor Action<R>? That seems very limiting. What would it take to allow this?
There was a problem hiding this comment.
Extend the conditional implementation feature as described for arrays to arbitrary types
There was a problem hiding this comment.
What about an anti-constraint like what was done for ref structs?
There was a problem hiding this comment.
I don't think you can write code that works for both owned types and unowned types in the same method. For the simplest problem, the owned type needs to Drop at the end and it has to have a bunch of exception handling to make that happen. In contrast, a GC type can't drop its parent.
I think the way you share code instead is writing it against a borrow. That would allow you to write the same code for both modes and the rules around borrows would make sure that you aren't doing anything illegal. As long as both GC and resources can be converted to borrows, the code is sound in both cases.
| ```csharp | ||
| interface IResource | ||
| { | ||
| void Drop(); |
There was a problem hiding this comment.
Should we support async Drop too? I imagine many IAsyncDisposables might want to be converted to IResource.
There was a problem hiding this comment.
Drop shouldn't throw, arbitrary I/O likely should throw
| - Assigning an owning reference to another owning reference is considered a transfer of ownership. | ||
| - If the variable may be assigned before another assignment, assigning it ends its lifetime | ||
| - After transfer, use of the original reference is disallowed. | ||
| - There may be only one mutable reference, or any number of read-only references (aliasing rules). |
There was a problem hiding this comment.
Does this apply to interior references, i.e., can I have two mutable references to fields of a resource?
There was a problem hiding this comment.
Current lifetime spec doesn't describe a difference for interior borrow, so that's not possible
| ```csharp | ||
| interface IResource | ||
| { | ||
| void Drop(); |
There was a problem hiding this comment.
I wonder if we could just say that a resource must be dropped via Dispose but we left it to the user. That would connect more naturally to existing C# IDisposable, would be less breaking to migrate to, would be more explicit where disposal vs ownership transfer happens (but still easy to use via using), would make it possible to not need try/finally everywhere (leaving a resources undropped after exception isn't a memory safety issue, it's just a memory leak, right?).
There was a problem hiding this comment.
Just because leaking isn't a memory safety issue doesn't mean that leaking cannot be catastrophic for system performance and stability.
There are also many other problems:
- Dispose had the wrong semantics (can be called twice)
- using has the wrong structure (structure-based, as opposed to data flow)
- existing dispose implementations may not be valid drop implementations
- you can't use the interface implementation to imply ownership tracking
There was a problem hiding this comment.
- Dispose had the wrong semantics (can be called twice)
- existing dispose implementations may not be valid drop implementations
I meant this would still be opt-in via IResource (or some attribute).
- using has the wrong structure (structure-based, as opposed to data flow)
using on a resource could work differently.
- you can't use the interface implementation to imply ownership tracking
That can be disallowed.
Just because leaking isn't a memory safety issue doesn't mean that leaking cannot be catastrophic for system performance and stability.
If you use using and your Dispose doesn't throw, there are no leaks.
There was a problem hiding this comment.
I think the problem is, a using statement can be omitted, accidentally or otherwise. And while memory leak is not an immediate problem, it is still a critical failure in an application - consider a game that allocates every frame. It feels like a pit of failure. I think having to use a using statement to drop an IResource muddies the big win of this proposal - that the lifetime/scope of the object dictates when it is dropped, not the user calling using on it.
Also consider a world where lots/most types are IResources - all the usings would just be noise.
There was a problem hiding this comment.
I think the problem is, a using statement can be omitted, accidentally or otherwise.
Not with this feature, the compiler would complain about it.
There was a problem hiding this comment.
Like Jan, I'm wondering about how IResource relates to IDisposable/using:
IResourceoffers deterministic cleanup for "resources" and ownership disciplineIDisposable/usingoffers deterministic cleanup for "resources"
Is there a way to rationalize them together? Can we re-use/reduce concepts?
Just spitballing:
Dropvs.Dispose: similar concept of deterministic cleanup, but also some differences. we can generate part of it for owned resource, and there's also the problems that Andy pointed out including Dispose semantics. We can just tighten the expectations forDisposewhen it comes to owned resources.- Would it make sense to separate concerns by having the marker interface (perhaps
IOwned) only mean "ownership discipline"?
Then you could have permutations:class C : IOwned(only ownership discipline),class C : IOwned, IDisposable(ownership discipline plus deterministic cleanup) and possibly evenclass C : IOwned, IAsyncDisposable. - Should usages with deterministic cleanup involve explicit
usingsyntax? It's a bit more syntax, but blends with existing C# (we'd extend the meaning ofusingon owned resources) and surfaces the implicit try/catch.
The lowering for owned resources would differ from today, to be aware of whether the ownership was transferred.
You could say there are two ways of discharging the cleanup obligation: 1. you transfer ownership, or 2. you useusingto cleanup when ownership could remain.
In that view{ File f = new File(); f.ReadBytes(); }would be an error because the obligation to clean up this owned resource is not discharged (either byusingor transfer).
There was a problem hiding this comment.
It does somewhat feel like ownership without a drop/dispose/free is a potentially useful thing; for example, an array builder type might want to opt-into ownership so that it isn't reused after a call to ToArray(), but doesn't actually have anything to do in that drop/dispose step, since ownership of the underlying array was transfered by ToArray in one case, and in the case that the builder was abandoned, just needs the GC to come and free both it and its underlying resources.
There was a problem hiding this comment.
I've been toying with some ideas in my extension of this proposal: jjonescz#1
There was a problem hiding this comment.
Also think about beginners learning C# and seeing both IDisposable and IResource - this will create a lot of confusion
And think about experienced engineers learning/switching to C# - they see IDisposable and IResource and I'd guess immediate thought would be - "bad design"
Co-authored-by: Jan Jones <jan.jones.cz@gmail.com>
|
This looks like a solid step forward!!
static Borrow<Node> GetOrAdd(ref Map map, int key)
{
if (map.TryGetBorrow(key, out var value))
return value; // This path extends the borrow to the caller.
map.Add(key, new Node()); // No borrow exists on this path.
return map.GetBorrow(key);
}
noncopyable struct RentedBuffer<T>
{
// The returned Span borrows this. The relationship is inferred.
public readonly Span<T> Span { get; }
public consuming void Dispose();
}
static void Fill(borrowing RentedBuffer<byte> buffer);
static byte[] Freeze(consuming RentedBuffer<byte> buffer);
var buffer = Rent();
Fill(buffer); // Shared borrow
Span<byte> view = buffer.Span; // Keeps buffer borrowed
Use(view); // Last use ends the borrow
byte[] result = Freeze(move buffer);
Use(buffer); // Error: use after moveThis makes three otherwise hidden facts fairly clear:
|
jaredpar
left a comment
There was a problem hiding this comment.
The document needs to do a better job of outling the restrictions around resource types. For example, the inability to call ToString or participate in any patterns that convert values to string representations which are prevalent throughout the .NET BCL and ecosystem.
| - Assigning an owning reference to another owning reference is considered a transfer of ownership. | ||
| - If the variable may be assigned before another assignment, assigning it ends its lifetime | ||
| - After transfer, use of the original reference is disallowed. | ||
| - There may be only one mutable reference, or any number of read-only references (aliasing rules). |
| - After transfer, use of the original reference is disallowed. | ||
| - There may be only one mutable reference, or any number of read-only references (aliasing rules). | ||
| - `R` may not be substituted for generic type parameters | ||
| - `R` may not be boxed into `object`, an interface, `dynamic`, or captured inside a delegate. |
There was a problem hiding this comment.
Can't this be simplified to R may not be converted to a non-resource type? Also I would separate out the conversions and capture into different rules here cause they can be addressed differently.
| local.M(); | ||
| ``` | ||
|
|
||
| In the above code, the expression `local.M()` produces an error. This is because `Helper(local)` has taken ownership of the value called `R`. After ownership has been transferred, the previous owner of a value may no longer access it. |
There was a problem hiding this comment.
I worry that implicit move operations will be a net negative. As structured it means the reader, or agent, needs context to understand when values are usable or not. It needs to know what interfaces R implement, what overloads of Helper are being called, etc ... to know if the code will compile. I would consider a more explicit form of transfer here.
| local.M(); | ||
| ``` | ||
|
|
||
| In the above, `local.M();` is an error. `r2 = local` did not make a copy or create a new alias — it took ownership of the value, and `local` lost it. |
There was a problem hiding this comment.
In this section you should discuss how this code sample works:
R local = new R();
local.M();
local.M();Is this legal or illegal? It's important to understand how the ref semantics work before getting into borrowing.
| - `Drop` is invoked when leaving the variable's lexical declaring scope | ||
| - This includes exception unwinding | ||
| - When an owning variable is re-assigned, `Drop` is called on the overwritten value | ||
| - Drop implementations should not throw |
|
|
||
| > **N.B.** All instance members have a borrowed receiver. | ||
|
|
||
| Any by-ref, borrowed reference, or value carrying a by-ref or borrow lifetime returned from an instance member of a resource type is considered derived from the member's implicit borrowed receiver. Its lifetime is therefore no wider than the lifetime of `this`. Conceptually, a `Span<T>` returned from a member whose receiver is `Borrow<$a, R>` is treated as `Span<$a, T>`. |
There was a problem hiding this comment.
may want to rewrite that paragraph in english :)
There was a problem hiding this comment.
Hey, at least no brought up a lattice.
| { | ||
| get | ||
| { | ||
| Span<T> span = _rented; |
There was a problem hiding this comment.
Please define _rented so readers can better understand what behavior is being demonstrated here. As written it's unclear what the source is here hence the borrowing / owner relationship is unclear.
| These states are mutually exclusive. While any borrow is live, the owner | ||
| may not be accessed, transferred, reassigned, or dropped. | ||
|
|
||
| To accommodate read-only and mutable borrows we also have to adjust the rules for classes themselves. The `readonly` keyword will now be legal for class methods, just like struct methods. It will have the same rules. An ordinary (non-readonly) class instance method will have a `Borrow<this>` receiver. A `readonly` instance member has a `ReadOnlyBorrow<this>` receiver. Importantly, read-only borrows may only call readonly members. Note that this is shallow mutability -- non-resource members may effectively be mutated due to lack of mutability requirements on interior members. |
There was a problem hiding this comment.
Do you plan to allow class types to take on readonly members? Otherwise, not much point in a ReadOnlyBorrow<T> of a class type
| - There may be only one mutable reference, or any number of read-only references (aliasing rules). | ||
| - `R` may not be substituted for generic type parameters | ||
| - `R` may not be boxed into `object`, an interface, `dynamic`, or captured inside a delegate. | ||
| - `R` may only inherit from `object`, `ValueType`, or another resource type |
There was a problem hiding this comment.
Should discuss here whether R[] is legal or not.
| * Arrays do not carry aliasing restrictions, meaning that multiple aliases to the parent array could appear, violating unique ownership | ||
| * Arrays would not call Drop on the elements | ||
|
|
||
| All of these problems are solved using a new language construct: _conditional implementation_. In existing C#, all types either implement an interface or not. This proposal would add a new special case, just for arrays: for a substituted type `T[]`, `T[]` implements the `IResource` interface if and only if `T` implements `IResource`. Correspondly, `T[]` is a resource if and only if `T` is a resource. The implementation of `Drop` is also specified as walking the array from beginning to end and calling `Drop` on each element. |
There was a problem hiding this comment.
It's not really a new language or language construct. Arrays already conditionally implement IEnumerable<T> based on the type of T. This doesn't seem that different.
There was a problem hiding this comment.
Oh because of pointers? Yeah I forgot about that feature
There was a problem hiding this comment.
Yep it's the pointer / __makeref type issue.
|
|
||
| The simple answer is that an ownership system may be used to manage memory, but that isn't the only thing it can do. C#'s memory model views memory as infinite, but there are many other things in C# apps that are not. For instance, file handles on most operating systems are both finite and easily exhaustible. They should also not be accessed after they have been closed, and they should only be closed once. Similarly, the standard library has a popular API called `ArrayPool`. Arrays may be borrowed from the pool using `Rent` and returned using `Return`. Returning the same object twice is invalid, as is using the object after it has been returned. The results can be severe — multiple writers may believe they own the pooled object and effectively cause memory corruption. This has caused serious issues in practice — multiple CVEs have been issued in the framework due to incorrect usage of `ArrayPool`. | ||
|
|
||
| Even simple cases that don't seem to look like memory management may be solvable using ownership. There is a popular .NET type called `ImmutableArray`. Because `ImmutableArray` is a fully immutable type, it is often created using a builder type that produces the immutable array using a final `ToImmutable()` method. Internally, even if the array is exactly the right size, a copy is made. This is because there is no way to prove that the builder is discarded after `ToImmutable` is called. If it is not, the builder would still have a reference to the underlying array and could mutate the contents — even though it is supposed to be impossible. Ownership provides the exact semantics desired — if `ToImmutable` takes the Builder ownership, it can ensure that no copies were made and no references are still valid. Therefore, it would be safe to return the underlying array. |
There was a problem hiding this comment.
I'm not sure ImmutableArray is the best example
Namely because I'm not sure any version of this feature would allow ToImmutable itself to change behavior here. It's more an optimization but one that would require passing info into ToImmutable itself to relay that this becomes dead on return and so it is safe to hand back the underlying array directly. -- It's also not always possible because Capacity may not equal Count, in which case the size would be incorrect.
Instead we have MoveToImmutable (throws if capacity != count) and DrainToImmutable (copies if capacity != count) where these concepts were designed up front, but also designed in a way so-as to avoid the ownership issue in the case a move does occur by setting _elements to empty after, allowing the builder to still be safely used.
So I think this is rather an area where it would have had to be designed with that intent up front and where its very different from say ArrayPool where it is instead a correctness concern and one we'd be able to take a change or break around (since its not changing behavior so much as enforcing correctness)
| To indicate that a C# type is an owned resource we will introduce a new interface — `IResource`. The definition of `IResource` is as follows: | ||
|
|
||
| ```csharp | ||
| interface IResource |
There was a problem hiding this comment.
This is a very generic name and highly likely to be conflicting, both for our own code and 3rd party. We may want to consider something that is clearer on semantics and less likely to be problematic.
Existing types shouldn't have an issue opting-in to it, but generic methods would since it requires a constraint change. You'd at best get if (T is IResource ...) { } on existing generic methods
| } | ||
| ``` | ||
|
|
||
| Any type that implements `IResource` will be considered an owned resource, which carries some special requirements and privileges. Types will not have to provide an implementation for `Drop()`; the language will automatically provide an implementation that calls `Drop` on all fields that implement `IResource`, in field declaration order. If a type does provide an implementation of `Drop`, that is _in addition to_ the default implementation — after a user implementation of `Drop` is run, the language defined transitive `Drop` implementation is run. This ensures that resources cannot accidentally forget to be dropped. |
There was a problem hiding this comment.
We may want to consider how this can be used on existing types, especially ones where we know there is effectively ownership or "move only" semantics and where copying can be catastrophic (most mutable structs, for example).
SpinLock is a trivial case that has this issue and likely should be opted into whatever tracking is available both from the "resource" perspective (its a lock with one holder, that may or may not be re-entrant) and that copying the struct represents a failure (because future mutations won't be touching what you think it should be touching).
There's plenty of time for debating syntax. This is not that time. 🙂 However there's one thing worth calling out:
This is not how ownership works. Noncopyable must mean non-aliasable as well. And Dispose shouldn't be optional -- the language should do its best to prevent you from leaking or in some other way losing track of the object. In fact, one of the primary benefits of this system is that it very hard to lose track of types. |
| - When an owning variable is re-assigned, `Drop` is called on the overwritten value | ||
| - Drop implementations should not throw | ||
|
|
||
| ### Borrowing references |
There was a problem hiding this comment.
Have you thought about dedicated syntax for borrowing? Even if it's a "we can think about it after initial debate", I do think it's at least worth a mention.
There was a problem hiding this comment.
& and &mut prefixes are technically available. Maybe riff on that?
There was a problem hiding this comment.
I was actually thinking more borrow or mut borrow, but my general concern is just making sure that readers understand that we aren't saying that we won't have a syntax, we just need to consider it.
There was a problem hiding this comment.
I'm unsure about the ergonomics of types over syntax as well. I'd throw borrow and readonly borrow into the hat. This could also help avoid the asymmetry between classes and structs
| ### Drop rules | ||
|
|
||
| - `Drop()` is compiler-invoked and cannot be called directly. | ||
| - `Drop` is invoked when leaving the variable's lexical declaring scope |
There was a problem hiding this comment.
This rule seems incomplete. As stated, it implies that Drop is called in this code:
{
R local = new R();
Helper(local);
} // leave local's lexical declaring scope
Is the intent that Drop will only be called at most once on a resource (when its ownership ends) or that we skip calling it in some cases or that it can be called more than once?
There was a problem hiding this comment.
Also, consider including some examples showing when Drop is called. What happens with this:
R local = new R();
if (b)
{
Helper(local);
}
There was a problem hiding this comment.
I'm repeating myself but I think Roles would help massively here. Maybe Linear and/or Affine Roles, for instance, if borrowing is to be conceptualised in terms of typing.
| To represent borrowed class references, two new special types will be added to the core library. The definition is as follows: | ||
|
|
||
| ```csharp | ||
| struct Borrow<T>(T value) where T : class |
There was a problem hiding this comment.
Is there a benefit to keeping these intrinsic types in metadata for the runtime? Maybe metadata erasure would be better since we want all those types to behave like the underlying anyways: modreq(Borrow) T, modreq(ReadOnlyBorrow) T, modreq(Own) T.
That raises a question though: do we want to allow overloads to differ by such annotations?
M(ref StructResource) vs. M(ref readonly StructResource) is disallowed already, would we want to similarly disallow M(Borrow<Resource>) vs M(ReadOnlyBorrow<Resource>)?
| However, converting just GC types to borrows is not particularly useful on its own. We would like to be able to treat a class as either GC-tracked or owned, _per instantiation_. To do so we will need another instrinsic type: | ||
|
|
||
| ```C# | ||
| public readonly struct Owned<T> : IResource |
There was a problem hiding this comment.
Do we have some motivating examples for ownership and borrowing of ordinary classes? The worked examples below only illustrate resource/IResource cases.
|
|
||
| void IResource.Drop() | ||
| { | ||
| _pool.Return(_rented); |
There was a problem hiding this comment.
This is technically a safety issue, given that Drop() would be called from a finally block, and it would be returning the array to the pool from there. My understanding is that the guidance is to explicitly not return arrays to the pool from a finally block, because (as @GrabYourPitchforks said), if there's an exception you cannot make assumptions on the state of the process, so it's safer to just throw that rented array away. Basically you should only be returning it in the non-exception case, otherwise let it be collected.
Should we have a way to define a resource that should be dropped without a finally block? This has come up before multiple times already for similar scenarios. E.g. completely spitballing here, say you had a [SkipFinally] or [SkipDropOnFault] attribute (or whatever name) that you could add to the resource type. In that case the compiler could see that and just emit the call to Drop() inline after the other statements of the scope that just finished, without injecting a try/finally.
Otherwise e.g. this shouldn't really be used for array pooling at all 😅
There was a problem hiding this comment.
I am thinking the logical way is to have 2x Drop - 1 for exceptional and one for non-exceptional - this can then work well with the auto-impl aspect & generics. JIT should hopefully be able to opt
try
{
...
x.DropNonCritical();
}
fault
{
x.DropCritical();
}when DropNonCritical and/or DropCritical are no-ops (or if not, then it could be fixed).
There was a problem hiding this comment.
My understanding is that the guidance is to explicitly not return arrays to the pool from a finally block
It is "consider" guidance. It only applies when the exceptions are expected to be very rare on the given code path.
It is defense in depth for bugs in manually managed lifetime. It is not relevant when the correctness of lifetime management is enforced by the language.
There was a problem hiding this comment.
In the case of array pools this might be fine, but for other objects they may end up in a broken state in some exceptional cases - imo having a way to have a non-exceptional/non-critical drop would be good - I know I would certainly use it.
| * It has no public constructor. The `Owned` type effectively re-exports the constructors of `T`. Each constructor must be marked `BorrowedReceiver` to indicate that it is borrow safe. | ||
|
|
||
| * Existing instances of `T` may never be converted to `Owned` -- `Owned` must completely control the lifetime of the instance. | ||
| * Direct access to `Value` is prohibited. Instead, all `BorrowedReceiver` methods on `T` will re-exported as theough on the `Owned<T>` instance and the compiler is responsible for translating all such invocations through `Value`. |
There was a problem hiding this comment.
| * Direct access to `Value` is prohibited. Instead, all `BorrowedReceiver` methods on `T` will re-exported as theough on the `Owned<T>` instance and the compiler is responsible for translating all such invocations through `Value`. | |
| * Direct access to `Value` is prohibited. Instead, all `BorrowedReceiver` methods on `T` will re-exported as though on the `Owned<T>` instance and the compiler is responsible for translating all such invocations through `Value`. |
|
|
||
| 1. Walker, David (2002). "Substructural Type Systems" | ||
| 2. The Rust programming language for general understanding of ergonomics | ||
| 3. Special thanks to [boats](https://without.boats/)), who's writing has heavily influenced my framing of how to think of a GC hybrid language |
There was a problem hiding this comment.
| 3. Special thanks to [boats](https://without.boats/)), who's writing has heavily influenced my framing of how to think of a GC hybrid language | |
| 3. Special thanks to [boats](https://without.boats/), who's writing has heavily influenced my framing of how to think of a GC hybrid language |
|
|
||
| void IResource.Drop() | ||
| { | ||
| _pool.Return(_rented); |
There was a problem hiding this comment.
Is it allowed to have RentedArray a = default; or RentedArray[] a = new[5];? Then presumably this Drop implementation should handle null _pool or it will crash.
No description provided.