Closed Araq closed 4 years ago
There are some (a couple) of views on the delete procedure.
delete gets an incoming Node "elem" that is a shared_ptr but can't be used later anymore. Problem: delete can't set it to zero because it's a value. But there could be an attribute kill
in the param list that allows the callee to dereference the count of the Node. Or, better in this case, the expected refcount of "elem" has to be 1 or less. Anyway, the kill
attribute gives safety to both the caller and the callee. It statically assures that "elem" can't be used anymore. And it even seems to avoid a refcount incrementation (on the heap) , when the "elem" unowned ptr is created on the call site. (*)
delete as a a function, that removes a given node from a list and returns it as owned. This does not affect the refcount in the delete function! Then, it is up to the caller to deal with the returned node. To get rid of it, "elem" has to be set to null. When the returned (owned) Node gets out of scope, the refcount will be checked for zero and then deallocation takes place. In Pony, they have a thing called "destructive read"; e.g. a=b=a exchanges the values of a and b. The exchange at call site, in delete ,would be:
result owned
= elem.prev.next = elem.next,
whereby:
result owned
= elem.prev.next <=> result owned
= elem (logically)
EDIT: changed back to prev , some remarks added...
(*) (In other functions, the Node that "elem" points to could survive - it could be rebound to another owned ptr on the heap. Depending on context, this can lead to further deallocation or to exchange/"destructive read type" described above (pt 2). However, since the caller can't see what really happens in the called procedure, the caller has to nullify the "elem" anyway. With static analysis, no assignment will be performed. Instead, the Node simply becomes inaccessible. In all cases, it is presumed that the "elem" has been drawn from the var List. If the elem itself was passed as a "val" parameter , the caller itself beeing a callee, then it's not clear for me how to avoid the dangling pointer. It is a chicken and egg problem...)
@SixteNim I looked at the example more closely, and I agree with you that the delete
implementation described in Araq's linked list example does not work.
IIUC, the program would abort on this line:
# This will abort because elem.prev.next has a non-zero reference count at re-assignment time.
if elem.prev != nil: elem.prev.next = elem.next
The Gel paper does not explicitly describe any delete operation for the linked list, but I believe your 2nd solution is the closest to the philosophy of the paper, based on section 2.1 Fields and the take
operator they describe.
Essentially the the delete
procedure has to take ownership of the element, and return it to the caller.
Update: I talked to Araq about this on IRC. (see logs here: https://irclogs.nim-lang.org/01-04-2019.html#20:41:42)
delete
becomes unlink
(Then, the caller can safely disarm the un-owned pointer and then dispose and de-allocate the newly returned owned reference.)I prefer this option as it gives the caller more control, but it is a large API change with semantics that people may not be familiar with (especially coming from other languages).
proc delete[T](list: var List[T]; elem: var Node[T])
where elem
is still an un-owned pointer, but is an "in-out parameter" such that it can be set to nil, and thus disarmed, inside the delete
function. Because it's in-out
, the change will be reflected in the calling scope.This isn't as intuitive as I would like, but does provide the correct semantics.
@rayman22201 @SixteNim Returning an owned node may work for this case, even if it implies a change in the API. But this is just an example. It is not clear to me how to generalize this mechanism.
In general, a procedure may delete anything that is reachable through its arguments via an owned ref. Should we then force the programmer to return a tuple of owned refs containing everything that may be deleted in the procedure? This would make for some strange APIs, especially if one already wants to return something else in the procedure. I am not even sure that this can be enforced statically, so it probably would boil again to discipline.
In Araq's case, a sink
keyword can do the work. With sink, the callee expects "refcount zero"(*). This is only possible, if the caller marks the ref and _affected _aliases__ as unaccessible.
delete needs to be declared with elem :Node sink
. That clearly states that elem is not accessible longer after the "delete" call. And no other alias affected with it . The elem itself has to be either an owned pointer or an ephemeral ref or an alias. The number of aliases is not relevant. But they need to be grouped together. If this is statically possible, then they are sink-able. If not, then the ref has to be passed with refcount(+1), it becomes "unsinkable" and therefore this should raise an error at compile time.
#from import
proc gimme_da_object (list :List, key :int) :owned ref = #........
proc showme_da_object (list :List, key :int) :ref = #........
proc myfun(mynode :Node sink) = #.....
#end import
#body current proc starts now
var a :Node = gimme_da_object(mylist, key1) # owned ref
let b :Node = showme_da_object(mylist , key2) # normal ref and ephemeral at this point
let c :Node = showme_da_object(mylist , key3) # etc....
let d :Node = showme_da_object(mylist , key4)
let e :Node = if random()>0.6: a else: b
let f :Node = if random()>0.5: c else: d
let g :Node = a
#lousy lousy, but let's try something....
a.data = 42 # ok.
var x = f.data # ok.
var y = e.data # dito
myfun e # no! ... ok. lets try something else
myfun g # this seems to work, nice...
x = e.data # oh no! (Why?)
x = c.data # no problem
myfun f # works! surprise surprise !
y = c.data # oh no!
# we are done. No declared var or val can be used anymore. No deallocation necessary though.
(*) Double linked lists typically have a minimal refcount "1" , due to the prev_pointer. This needs to be handled by the delete procedure.
@SixteNim I have troubles parsing your example, can you explain what you mean by ephemeral ref
and alias
? Also, what does it mean The number of aliases is not relevant. But they need to be grouped together
?
@andreaferretti ephemeral ref is a ref, that has been "drawn" from the heap and exists on the heap as an owned ptr
. So, the ref doesn't own the object. However, the ref itself is not an alias. But when the ephemeral ref is copied, then there is an alias. And there can be a couple more and many more. Given sufficient statical analysis (something that Araq presumes too), they altogether add to the refcount of the object with max. one. In the sink
case it is zero. Otherwise, one. In my example given above, the refcount of e is one. But any other ref or aliased ref can be passed with refcount zero to the myfun
function. ( I should declare the gimme_da_object though....) Grouping: When an object gets out of scope , then the owner and every alias of the owner gets affected. They together are a "group", in fact, they are a set.
@andreaferretti
I am not even sure that this can be enforced statically, so it probably would boil again to discipline.
The Gel paper shows that this is in fact enforcable. As @SixteNim says, the refs and aliases do form a set. The Gel paper specifically describes it as follows, in the introduction of the paper: "all objects in a running Gel program belong to a set of ownership trees".
My first option is viable for any collection, it is a general solution. You are correct that you could end up in a situation where you are returning a tuple of unlinked references up the call stack. I think that is highly unlikely to happen in practice, and is a "code smell", but that's just my opinion. I did say that it was the more opinionated solution, and not everyone would like it.
In general, a procedure may delete anything that is reachable through its arguments via an owned ref.
This is not true! The condition that "a delete can only occur if the ref-count is 0" still applies, and is enforced! A procedure may delete anything that is reachable through it's arguments via an owned ref, if that ref has a ref-count of 0.
The second option, using in/out
parameters is more viable, and what the Gel paper uses. It's not explicit in the paper, but based on the fact that Gel is based on C#, this is the obvious solution that they use.
Using that option, an arbitrary procedure could have in/out
parameters for the aliased / unowned
refs, which can be disarmed
by setting them to nil
. It's pass by reference, so the caller would see this change, and the ref-count would be correctly adjusted.
In a way, this is the inverse of the first option I proposed. Instead of passing a set of owned refs
back up the call stack, you pass a set of un-owned refs
down to the procedure.
Either way, it will be enforced by the compiler and runtime. So it is not purely left to "programmer discipline".
Deletion is a special case, even Rust and Swift have trouble handling deletion for the general case. The api's will have to change in some way to compensate with this memory management scheme. But I don't expect them to be particularly "strange" or unusable in practice. I truly believe that is FUD.
As @SixteNim points out, you could use sink
to verify common ownership pre-conditions. sink
is a special form of ownership transfer (one that guarantees no aliases exist at the time of transfer).
Araq brings up a good semantic point about deletion and sink
though.
In his words:
"'sink' says "I'm taking over", it's for add-like operations" but "delete doesn't say "I'm taking over", it says "I'm gonna nuke this one""
Well, an alternate solution for Araq's delete
can be given with the precursor of the object-to-delete. This works for both singly and doubly linked lists isn't it. In both cases, delete
would work without the sink
annotation, accepting unowned
references. delete
recovers the deletable object with precursor.next
then. The compiler might still optimize , avoiding unnecessary (costly) increments / decrements of the precursor's refcounter on the heap.
delete
doesn't deal with owned nodes. All nodes in a list are already bound to an owned ref
on the heap, so the delete can never take an owned Node
. However, there are cases where a Node sink
is required. E.g. a proc
could take such a Node
and shift it to a new place. Surprisingly, it is even possible with Node
(unowned) via prev
(if available), that points to to the Node
with the owned Node
pointing to the object itself. Having both the object and the "owner", the callee can do many things with it.
I think we need a variation of sink T
that was called kill T
, then proc delete becomes
proc delete(list: var List; elem: kill Node) = ...
with the obvious semantics: It's ensured statically that after delete(list, n)
the n
is not accessed afterwards. In this sense unowned ref
has an assignment operator but passing it to a kill
parameter is a move-only passing mode. I don't really like this parameter passing mode but it's justified by the overall design paradigm here: Memory safety is attached to a container and every operation of the container needs to be precise about its semantics. Who knows, maybe the lack of kill
in Rust is what costs it the required expressivity to model lists.
That said, it remains to be seen how general kill T
really is and what other type extensions or parameter passing modes are required though my gut feeling is that with kill
it's finally complete...
@rayman22201
This is not true! The condition that "a delete can only occur if the ref-count is 0" still applies, and is enforced!
Sorry, by enforceable I was meaning enforceable at compile time, and the ref count is of course not available at compile time.
I truly believe that is FUD.
I don't mean to throw FUD, I am just genuinely worried about how a Nim without GC will look, and how much more complex it will become.
As @SixteNim points out, you could use sink
Sorry, I am not familiar with sink. I tried to read Araq blog post about it, but it was not very understandable for me.
@Araq
I think we need a variation of sink T that was called kill T
This exemplifies my worry better than anything I could come up with. Your proposal for a new runtime originally addedsink T
. Then, it turns out a new owenrship concept was needed, and owned T
is being proposed here. But even for a simple example this is not working, so now we have kill T
.
If Nim becomes what you are describing, I don't think it is for me. I don't mean to be polemic, and I will restrain from commenting further on this issue unless I am asked explicitly, but the semantics is just getting more and more complex, and I am still not sure all cases are covered. I don't know, maybe coming from C++ all these concepts are natural, but I don't think I can manage to program in a language where I can pass parameters in 5 subtly different ways (T
, var T
, sink T
, owned T
, kill T
).
Your proposal for a new runtime originally added sink T. Then, it turns out a new owenrship concept was needed, and owned T is being proposed here. But even for a simple example this is not working, so now we have kill T.
owned T
is not needed, the destructors work fine without them. But if we go the extra mile the complexity decreases further since we can avoid a GC mechanism. That's the idea anyway, maybe it doesn't work out, but the idea is to reduce the complexity, quite ironic, isn't it. For example we can then remove from the language:
deepCopy
..gcsafe
effect.but I don't think I can manage to program in a language where I can pass parameters in 5 subtly different ways (T, var T, sink T, owned T, kill T).
owned T
is not a parameter passing mode and if the code has no clean ownership I doubt it'll ever work reliably on multicore.sink T
remains a pure optimization, if you don't understand it, don't use it, it's for writing containers.T
vs var T
always were with us.kill T
doesn't exist yet and here is the thing: You need to give it a try before you say "I don't think I can...". How often do you write ref
based containers? @andreaferretti Nothing wrong with your concerns. You were the first who pointed out in:
The doubly linked list example is in the paper, and it seems to agree with @Araq description. In the paper, I could not find an implementation of delete, though. I am not sure whether delete, as implemented in the original post by @Araq, would pass the type checker from the paper.
that the original paper is somewhat incomplete. E.g. he delete function is lacking (something they can't do with their proposal). In fact, their proposal is a rather simple generational GC, strictly bound to the stack. Top on stack = young, deep in the stack = old (*). So they basically model the heap as an extended stack. Thanks to the heap, values on the stack can now have variable size. What's new : They introduce runtime checks for a static design. Sounds odd. And it is. However, there is a reason behind it: They emphasize that data with long lifetime ("old") should not have bindings to short living data. A Gel program that obeys the rule will never have a runtime error caused by wrong refcount.
You basically will not see the changes at the application side. It is self-explanatory if you delete something, you can't use it anymore. But the compiler doesn't understand "delete". The compiler has to follow requirements/capabilities rules. When you call a delete and the compiler finds kill T
- then it will remove the T and any alias of it from the scope (or disabling it with a flag). The basic difference between sink
and kill
: sink
worked only for owned
refs. But kill
works for unowned
. In this case, a delete
function has to recover the ownership of the ref. In the middle of a doubly linked list, it's easy. At the head of a doubly linked list it's not - the ownership has to be found somewhere else. ( In the List param. of course). With the recent GC in NIM, any inconsistency leads to the same result: Don't deallocate. However, these inconsistencies lead to data races. No one informs you and you feel good. In many cases, these inconsistencies are of minor grade: some minor glitches, but okay and no one remarks them. Rarely, they are not minor longer.
@Araq When to proc body contains an owned ref
(via new ) and and alias has been made, the compiler should take notice of it. In this case, a kill T
basically transfers an "owned ref". Will this raise an error? Furthermore, a T
passed as a parameter can't be transformed into a kill T
. Otherwise, this would break consistency and definitely lead to a runtime refcount error - the compiler should report this early. Still, if kill T
was already passed with kill, it's fine.
(*) and young dies soon, the older the longer (on the stack)
In fact, their proposal is a rather simple generational GC, strictly bound to the stack. Top on stack = young, deep in the stack = old (*). So they basically model the heap as an extended stack. Thanks to the heap, values on the stack can now have variable size.
This description is a unique way of putting it and I am still thinking whether it's true. :-) Can you elaborate? The "generational" aspect seems completely off, instead I would call it a "one bit" reference counting GC so stuff is freed as soon as it's dead and the RC is stored in the pointer value avoiding most of the RC overhead. And the RC here is the nil/not nil aspect of the owned ref, not the RC value they use for unowned refs.
@Araq
Can you elaborate? The "generational" aspect seems completely off, instead I would call it a "one bit" reference counting GC
There is no GC. Anything that goes out of scope gets deallocated. Period. The scope is the stack. There is a timeline: The top of the stack contains the youngest part of the scope. The "generational part" comes from an implicit barrier: "Young" data bound to an "old" ref is forbidden, otherwise you get a runtime crash. (You should get a compiletime error though). Generational GC's are focused on such barriers on memory regions, keeping them separate by "age". No memory regions for Bacon/Dingle though, they take the stack instead. That seems simpler to me. With Bacon/Dingle: The Scope is the Stack is the Reachability is the (implicit) Refcount (1)
They could do something else: with refcount-on (the explicit refcount) and for refcount>0, deallocation could be postponed and reported later. So you could still have a running (but potentially faulty) program. This would justify the effort of a tracking refcount in my opinion.
And the RC here is the nil/not nil aspect of the owned ref, not the RC value they use for unowned refs.
... And we just came to the conclusion that an owned ref
should not be set to zero dynamically but statically instead. It is the breaking point of the Bacon/Dingle system. There is a subtle difference between sink
and kill
: both exclude access after the resp. call. But kill
has to check for an owned
alias. If so, the owned
becomes (statically) unowned
.
The main questions regarding Nim are: It is doable? (yes). Are improvements necessary ? (probably). Can Araq do it? (yes). So what....
Er, that misses quite a lot, no offense. Say I have an a: array[N, owned ref T]
, if I update slot a[10]
then the old memory is freed (and that's good!). That's not stack-like at all.
Say I have an a: array[N, owned ref T]
I assume that the array is on the stack. This is access to fields. Static analysis can't do much here.
Formally, when you update a slot, you set the owned
to nil (deallocation takes place), the source , another owned
. gets then transferred to the a[10] . The source, formerly owned
, becomes either disabled like kill
( the best solution, if statically possible) or remains as an unowned
. Unfortunately(*) in this case, the refcount of a[10] has to be incremented (costly). With field access, things become less effective. Unavoidably, when the a finally leaves the scope, every single slot of it has to be checked for further deallocation. It's so sad.
(*) you could prevent it by setting the source unowned
to nil though
I assume that the array is on the stack.
No, it doesn't matter. It could be on the heap. For example, it could be your list of open browser tabs. You close a tab, the owned pointer is set to nil
, all the memory the tab consumed is freed. For me this models a heap accurately and has nothing to do with a "simple generational GC" as it doesn't use memory regions.
It could be on the heap.
Fine, what is the difference to a ref
Object then?
For me this models a heap accurately
I think the recent GC for Nim does the same. Bacon/Dingle look at the scope differently. And they reverse the ptr
ref
relationship. In Nim, ref
is counted , ptr
is not. Recent Nim uses ref
for owned
and ptr
for unowned
. There can be many ownerships though aka shared ptr
. No unique_ptr
. With Bacon/Dingle, ref
becomes unowned
and counted, but ptr
is now splitted in two uncounted refs: the ptr
remains and the other uncounted ptr is called owned
. In some aspects, it is an U-Turn. But anyway: The problem are the atomic pointers/references in the scope, not the refs on the heap. We already run in difficulties. These difficulties ar NOT Nim related. Static analysis becomes important. If done well, the potential may be enormous. If not, things will not getting better. High frequent up/down counting on the heap is a bad decision.
Fine, what is the difference to a ref Object then?
The point is that Bacon/Dingle do not model a stack, they model a heap. And it's not split into regions like a generational GC would do it, if you want to compare it to a GC, it's closer to reference counting than anything else.
We should discuss potential traps and flaws of the Bacon/Dingle model regarding Nim.
The point is that Bacon/Dingle do not model a stack, they model a heap. And it's not split into regions like a generational GC would do it, if you want to compare it to a GC, it's closer to reference counting than anything else.
Your GC is heap-based . The stack is a pointer-pool only , an array of u64 , potential refs to the heap isn't it. The Checkpoints for deallocation are zero-refs on the heap resulting in potentially further deallocation. The GC allows for sharing of refs arbitrarily. If Nim would take the Bacon/Dingle model as published, it would abandon the GC. It's not a "simply better GC".
The point is that Bacon/Dingle do not model a stack, they model a heap. And it's not split into regions like a generational GC would do it, if you want to compare it to a GC, it's closer to reference counting than anything else.
It is basically a malloc/free with unique_ptr
. Every object on the heap can be reached by a chain of unique_ptr
, Presumed, that the program is correct, nothing else is needed. If an owned
gets out of scope, deallocation takes place after checking the ptr for nil
. The same happens if a owned
gets nil
ified deliberately. Nice, but let's find out now where the pitfalls are.
I had promised to avoid more commenting but there is one point I think is important from this comment of @SixteNim
It is self-explanatory if you delete something, you can't use it anymore
This is not self-explanatory at all. I am deleting an item from a list, not wiping it out of existence, at least not intentionally. This is something I can do with a GC
list.delete(node)
# use node for something else, unrelated to its presence inside `list`
Maybe I am just moving it from a container to another
list1.delete(node)
list2.add(node)
The only solution really seems to return an owned reference to the caller, like
let node1 = list1.delete(node)
list2.add(node1)
@andreaferretti
list1.delete(node) list2.add(node)
The ´´delete´´ is an unlink. It should be: let enode = list1.unlink(node)
let enode
being owned Node
(inferred)
and then: list2.add(enode)
unlink gets a unowned
and returns an owned
Node. The unowned
should be passed as kill Node
. The caller gets the node back however!
list2.add(enode)
expects an owned
as shown.
Nice, but let's find out now where the pitfalls are.
What do you think we have been doing in this thread? ;-)
I may be all wet on this, but shouldn't the goal be to clarify dialog with the user? I agree with @mratsim that we need language constructs to codify our dispose
intent, especially since this is a particularly significant notation (and perhaps because dangling = nil
is so insignificant).
Explicit language isn't just easier for us to read and reason about; it's easier for the compiler to analyze and optimize, too. The rewards are more accurate static analysis, more precise debugging information, and superior optimization. These aren't Rusty hoops we have to jump through; these are the means by which we may sidestep those hoops, and (not least) they help the compiler better serve our actual intent.
If the specificity with which we can notate semantics improves, then I don't see how it will matter which backend the user selects -- the compiler will be able to do the Right Thing given the circumstances, both with libraries that assume a GC and with a scope-based MM technique and with whatever new-fangled automanual gizmo arrives in 2.0.
@disruptek It shall be called disarm
.
I chose nim for the reason of being a fast and elegant language that was easier to read and understand than c/c++ (while being almost as fast), but faster and less verbose than c#/java without sacrificing safety. I'm happy with the ptr/ref dichotomy, and though is not perfect I can work around it, since for the majority of my projects and code ref is enough. It removes the weight off my shoulders.
I'd be happy to know that v1.0 will be released under the initial assumptions on resource management, or else to know that the change will not happen overnight. And, if possible, to prioritize fixing the most important bugs first before any library adaptations.
I'm really happy to hear about this as it seems to be a major improvement to the language. I actually always assumed that Araq would find some nice ergonomic way to add more safety and efficiency.
However, I may need to scroll through again but from the discussion I am not 100% certain exactly where this leaves the 1.0 release and whether it actually is going into 2.0. Can I assume that there will be a 1.0 with libraries set up for GC that is available for use while we learn the new system and it gets refined?
I wonder if there is a way to distinguish between these major runtime discrepancies when it comes to packages and dependency management.
The current plan is:
owned
and disarm
annotations in Nim v1 but they are ignored unless --newruntime
is used which is experimental / vaporware / unusable.system.owned
and system.disarm
. Worst case: it turns out that everything fails and owned
is not worth its costs. Then you're left with pointless annotations about ownership that are neither checked nor enforced until we remove them again.
Just as untested idea: is it possible to use existing system.reset
instead of introducing new system.disarm
?
Nim v1 will ship with the existing GCs, what else.
Seems reasonable. Is there still some hope for a vtable
implementation ? Like Rusts "fat pointer" ?
Just as untested idea: is it possible to use existing system.reset instead of introducing new system.disarm?
Seems too risky, firstly it's not clear what reset
is for. Secondly, disarm
would only exist for unowned refs.
Seams reasonable. Is there still some hope for a vtable implementation ? Like Rusts "fat pointer" ?
There are Nimble packages that provide these things and v1 needs to be about stabilizing what we have.
I'm very interested in trying this feature if/when it is functional enough. There always seem to be some comments/fear about C# and managed languages, not having gc... etc. I'll not speculate here, but I did want to just mention my personal perspective as a potential nim user. I've got 3 years professional c# usage on 3d house design/engineering software. And 7 years as a pro c++ developer for mining optimization software. The only way I'm going to be able to touch these "less popular" languages is in my free time. Or if I can sell it to my boss :), of course. I love learning about them. Read the rust book. Read the nim book. Ported a good portion of the corange engine to rust as a hobby experiment to see what made the language tick. I would like to get into nim more. As far as I'm concerned there is little difference with coding a gc language, or a non gc from my user perspective: if the container object is thin & is on the stack, heap allocations are hidden behind some abstraction and there is some deterministic hands off destruction. All written by whoever is providing the structure. I know there are a lot of details left out there, but for "getting stuff done", it serves me. Pythonic just seems to be the syntax my mind likes best and nim has the macro system going for it, regardless of the semantics of object destruction. And it is compiled. I just wanted to chime in that this particular proposal was something that really struck me as intriguing for how I personally like to code.
The owned refs conceps seems to be similar to Pointer Ownership Model of David Svoboda
https://resources.sei.cmu.edu/asset_files/WhitePaper/2013_019_001_55008.pdf
https://resources.sei.cmu.edu/library/asset-view.cfm?assetid=55000
@Araq your blog post mentions backing refs with type-specific memory allocators. Right now in --newruntime
they still uses alloc0
.
Would ref
s eventually use Allocator
s, just like seq
? If so would that abstraction change? E.g. similar to c++ std::allocator<T>
and std::memory_resource
?
There is also -d:useMalloc
for --newruntime
and then it uses malloc with its ways to "override" it at linking time.
That's slightly ugly, since both the default allocator and new
use malloc
, so having malloc
call into an allocator isn't ideal. Also I wouldn't want to get rid of the default allocation strategy, but mix it with others.
I would instead suggest for new
to use the current local allocator, like seq
. refs
already behave much like intrusive pointers, so i would go all the way and have them carry around their allocator. Would you be ok with such a PR?
Bah, it's ok I guess, but we should have allocators that are page-aligned (or more than that) so that we can do bitmasking of pointers to access the underlying allocator, no need to carry the allocator around as a separate internal pointer everywhere.
Question: Why is it necessary to have a runtime error for dangling unowned refs? Could the runtime not simply set them to nil
? This would require the use of unowned refs to be guarded by nil
, which I think can be checked statically -- for instance, Kotlin and Eiffel do it, if I understand correctly.
Apologies if this is a dumb question, but it came to mind.
Setting weak pointers to nil automatically is much more expensive, you would need to keep a data structure around just to be able to do this. This is particularly problematic for unowned refs on the stack, it would require a register/unregister pair for every stack slot that has an unowned ref.
@Araq Had I but paused a moment to think of that, it would have been obvious. Thank you.
you would need to keep a data structure around just to be able to do this
But not necessarily a separate data structure. This is implemented by, for example, SaferCPlusPlus' "registered pointers" by making each pointer object double as a node in a linked list that tracks all the references currently targeting the given object.
The registering and unregistering does add some expense. SaferCPlusPlus provides both "auto-self-nulling" registered pointers and non-owning alias counting "norad" pointers like the references Nim seems to be adopting, and simple micro-benchmarks for them. The micro-benchmarks indicate that the copy and assignment operations of "auto-self-nulling" pointers are, as expected, more expensive than those of "alias counting" pointers, but not by as much as I might have thought. (Shared-owning (non-atomic) reference counting pointers appear to be in the same performance ballpark as well.) In practice, my experience is that there's rarely a noticeable performance difference in the application overall.
With this proposal, it seems to me that the primary use case for these non-owning references would be dynamic data structures with cyclic references. I suspect that in most of those cases either the differences in overhead of any pointer operations would be small relative to the cost of the accompanying memory allocation operations, or, for cache-coherency reasons, the organizational structure of the nodes (if not the nodes themselves) would be stored in a contiguous vector, in which case indexes could/would take the place of the non-owning pointers. No?
That said, I don't disagree with the choice of using "alias counting" as the safety mechanism. But I don't think it's an either-or situation. There are (maybe rare but) legitimate use cases for weak pointers that can handle the "untimely" destruction of their target. I think that ultimately you're going to want to provide both.
Most forms of weak pointers, including "auto niling" pointers solve a different problem, a "modelling" problem. For instance in a game your entities can "die" and then all the subsystems are "notified" about this. I don't think it's a good general purpose model -- when I write a BTree implementation etc I don't want to be notified about my now-dangling pointers, I want to fix my implementation so that it doesn't dangle.
Of course, with Nim's destructors and assignments you can implement all these things, you can also implement traditional reference counting, but the proposal is about what Nim's default, builtin ref
should do.
I don't want to be notified about my now-dangling pointers, I want to fix my implementation
Is this part of a general "Don't attempt to recover from program logic errors." principle? Is there an inconsistency with out-of-bounds array index errors throwing an exception rather than terminating the program? I wouldn't disagree that "bailing immediately" is probably the most appropriate course of action in most cases when encountering a logic error. But I don't know, I could imagine there being situations where attempting to handle it more gracefully would be, while not ideal, still preferable.
with Nim's destructors and assignments you can implement all these things
Yeah, that's a good point. If there is sufficient demand for these alternatives they'll likely end up being implemented anyway. Without adding clutter to the base language.
Is this part of a general "Don't attempt to recover from program logic errors." principle?
Yes.
Is there an inconsistency with out-of-bounds array index errors throwing an exception rather than terminating the program?
No, it is not, from https://nim-lang.org/docs/manual.html#definitions
"Whether a checked runtime error results in an exception or in a fatal error is implementation specific. Thus the following program is invalid; even though the code purports to catch the IndexError from an out-of-bounds array access, the compiler may instead choose to allow the program to die with a fatal error."
In fact, we restructured the exception handling to make this more explicit.
Whether a checked runtime error results in an exception or in a fatal error is implementation specific
Wow, that's strong. Is there a performance reason for doing so?
This RFC was succeeded by https://github.com/nim-lang/RFCs/issues/177
Owned refs
This is a proposal to introduce a distinction between
ref
andowned ref
in order to control aliasing and make all of Nim play nice with deterministic destruction.The proposal is essentially identical to what has been explored in the Ownership You Can Count On paper.
Owned pointers cannot be duplicated, they can only be moved so they are very much like C++'s
unique_ptr
. When an owned pointer disappears, the memory it refers to is deallocated. Unowned refs are reference counted. When the owned ref disappears it is checked that no danglingref
exists; the reference count must be zero. The reference counting can be enabled with a new runtime switch--checkRefs:on|off
.Nim's
new
returns an owned ref, you can pass an owned ref to either an owned ref or to an unowned ref.owned ref
models the spanning tree of your graph structures and is a useful tool also helping Nim's readability. The creation of cycles is mostly prevented at compile-time.Some examples:
We need to fix this by setting
dangling
tonil
:The explicit assignment of
dangling = nil
is only required if unowned refs outlive theowned ref
they point to. How often this comes up in practice remains to be seen.Detecting the dangling refs at runtime is worse than detecting it at compile-time but it also gives different development pacings: We start with a very expressive, hopefully not overly annoying solution and then we can check a large subset of problems statically with a runtime fallback much like every programming language in existance deals with array index checking.
This is how a doubly linked list looks like under this new model:
EDIT: Removed wrong
proc delete
.Nim has closures which are basically
(functionPointer, environmentRef)
pairs. Soowned
also needs to apply to closures. This is how callbacks can be done:main
is transformed into something like:This seems to work out without any problem if
envParam
is an unowned ref.Pros and Cons
This model has significant advantages:
owned ref
to C'srestrict
'ed pointers.shared_ptr
or Swift's reference counting.owned
keyword to strategic places. The compiler's error messages will guide you.And of course, disadvantages:
owned
annotations.nil
as a possible value forref
stays with us as it is required to disarm dangling pointers.Immutability
This RFC is not about immutability, but once we have a clear notion of ownership in Nim, it can be added rather easily. We can add an opt-in rule like "only the owner should be allowed to mutate the object".
Possible migration period
Your code can either use a switch like
--newruntime
and needs to useowned
annotations or else you keep using Nim like before. The standard library needs to be patched to work in both modes.owned
is ignored if--newruntime
is not active. We can also offer an--owned
switch that enables the owned checks but does use the old runtime.