How does Nim return values from a proc? Are basic datatypes (like int and float) returned by copy? What about more complex structures like sequences and strings?
For example, would this proc:
proc fillWith(n, count: int): seq[int] =
var s: seq[int] = @[]
for i in countup(0, count - 1):
s.add(n)
return s
return what was instantiated on line 2? Or would it copy over the values to new sequence and return that to the caller? What about custom objects?
TL;DR: Strings and sequences, like objects and integers, are copy on assignment. References are not.
Technically, values are always copied when returned from a procedure - there's not really any other way.* If values were to be always passed by pointer/reference, how would values that are stored on the function call stack persist after the procedure has returned?
What differs between the types is what is copied. Though objects, integers, and references are all copied to the previous procedure frame, the memory referenced by references is not copied.
What people often get confused about in Nim (and other low-level languages) is the difference between return and assignment semantics. Assignment semantics in Nim are quite similar to return semantics, with two** exceptions: string and sequence types. These types Nim attempts to make semantically similar to arrays, despite both strings and sequences being references to dynamically allocated memory. Strings and sequences, like objects, copy their contents when assigned. This can be demonstrated by the code below:
var a = @[1,2,3]
var b = a
a[0] = 4
echo "A: ", a
echo "B: ", b
Now, before you declare that this is an awful design flaw, I have the following explanation. For the C backends, the sequence (and string) types are represented roughly like this:
type
# An array of sequence data dynamically allocated at runtime.
SeqData{.unchecked.}[T] = array[0..0, T]
Sequence[T] = object
len, cap: int
data: SeqData[T]
Since sequences and strings are mutable, their array of data must be occasionally resized and reallocated when space is needed. Since there is no guarantee that the reallocated block of memory will have the same pointer, all references to the old data throughout the entire program's memory must be updated. Under the current scheme, this is a simple operation. Since sequence and string types are always copied on assignment, there is always at most one reference to the old data - the current variable holding the string/sequence. Other schemes would require tracking all points in memory that a string/sequence is referenced, which would be difficult (though not impossible, as most copying-style garbage collectors function like this).
Of these three the first is the most safe, allowing the string to be resized while also allowing it to be reference by multiple parts of the program:
type SeqRef[T] = ref seq[T]
var seqr: SeqRef[int]
new(seqr)
seqr[] = @[1, 2, 3]
var seqr2 = seqr
seqr[0] = 4
echo "Address of sequence reference one: ", repr(addr seqr[][0])
echo "Address of sequence reference two: ", repr(addr seqr2[][0])
(This is the scheme used by Python for its list type)
The second and third options involve using shallow operations. Marking a sequence or string with the shallow procedure will bypass the usual data-copying behavior for all further assignments to that sequence, while using the shallowCopy operator will perform a single assignment operation that bypasses the behavior.
var a, b, c, d, e: seq[int]
a = @[1,2,3]
# Perform a shallow assignment operation from a to b
shallowCopy(b, a)
# Perform a normal (copying) assignment from b to c
c = b
# Make c shallow, then perform shallow assignments to d and e
shallow(c)
d = c
e = d
echo "a: ", repr(addr a[0])
echo "b: ", repr(addr b[0])
echo "c: ", repr(addr c[0])
echo "d: ", repr(addr d[0])
echo "e: ", repr(addr e[0])
The problem with shallow operations is that once a sequence or string has been shallowly copied, it must not be modified. If it is, then you will can end up with some versions of the string that are out-of-sync. When a shallow sequence is resized, only the variable currently being modified has its reference updated; the other variables will still have references to the old data. Though the old data will still persist (so you shouldn't get null reference errors), this kind of behavior is unpredictable.
*Of course, how this is done depend on the underlying calling convention. **Well, there's also overloading the assignment operator, but that doesn't necessarily imply copying.
Good post. Def would say "look at the generated code". This is what i got:
proc fillWith(n, count: int): seq[int] =
var s: seq[int] = @[]
for i in countup(0, count - 1):
s.add(n)
return s
proc main =
let counter = fillWith(7, 3)
echo counter
main()
N_NIMCALL(TY95007*, fillwith_95003_3831700988)(NI n0, NI count0) {
// Define vvv
TY95007* result0;
TY95007* s0;
nimfr("fillWith", "test2.nim")
{ result0 = (TY95007*)0;
nimln(2, "test2.nim");
// Create vvv
s0 = (TY95007*) newSeq((&NTI95007), 0);
{
NI i_95038_3831700988;
NI HEX3Atmp_95051_3831700988;
NI T3831700988_3;
NI res_95054_3831700988;
i_95038_3831700988 = (NI)0;
HEX3Atmp_95051_3831700988 = (NI)0;
nimln(3, "test2.nim");
T3831700988_3 = subInt(count0, ((NI) 1));
HEX3Atmp_95051_3831700988 = (NI)(T3831700988_3);
nimln(1887, "system.nim");
res_95054_3831700988 = ((NI) 0);
{
nimln(1888, "system.nim");
while (1) {
NI T3831700988_4;
if (!(res_95054_3831700988 <= HEX3Atmp_95051_3831700988)) goto LA3;
nimln(1889, "system.nim");
i_95038_3831700988 = res_95054_3831700988;
nimln(4, "test2.nim");
// Add vvv
s0 = (TY95007*) incrSeqV2(&(s0)->Sup, sizeof(NI));
s0->data[s0->Sup.len] = n0;
++s0->Sup.len;
nimln(1903, "system.nim");
T3831700988_4 = addInt(res_95054_3831700988, ((NI) 1));
res_95054_3831700988 = (NI)(T3831700988_4);
} LA3: ;
}
}
nimln(6, "test2.nim");
// Assign to result. Does it allocate a new seq?
genericSeqAssign((&result0), s0, (&NTI95007));
goto BeforeRet;
}BeforeRet: ;
popFrame();
// Return vvv
return result0;
}
N_NIMCALL(void, main_95058_3831700988)(void) {
TY95007* counter0;
NimStringDesc* LOC1;
nimfr("main", "test2.nim")
nimln(9, "test2.nim");
counter0 = fillwith_95003_3831700988(((NI) 7), ((NI) 3));
nimln(10, "test2.nim");
LOC1 = (NimStringDesc*)0;
LOC1 = HEX24_95062_1689653243(counter0);
printf("%s\015\012", LOC1? (LOC1)->data:"nil");
fflush(stdout);
popFrame();
}
Technically, values are always copied when returned from a procedure - there's not really any other way.* If values were to be always passed by pointer/reference, how would values that are stored on the function call stack persist after the procedure has returned?
I guess that one could put the return value as the first one on the stack frame, so there is no copy involved. It seems to me that this is safe to do when one uses the special variable result. It depends on the calling convention, but I think it should be doable?
I guess that one could put the return value as the first one on the stack frame, so there is no copy involved. It seems to me that this is safe to do when one uses the special variable result. It depends on the calling convention, but I think it should be doable?
This is close to how most calling conventions return results larger than a pointer. For many calling conventions, the caller of the function allocates the storage for the result (usually from the stack) then passes a pointer to that memory as a hidden argument to the called function. The called function then writes to that memory upon returning.
Basically, it's like this:
type LargeObj = object
a: array[0..10, int]
b: string
proc foo(): LargeObj =
result.a[0] = 1
result.b = "hello"
return result
# The above becomes this when compiled:
proc foo(result: ptr LargeObj) =
result.a[0] = 1
result.b = "hello"
return
Well, as I said, only the actual reference (the pointer) is copied on return. Its when it returns as the right-hand side of an assignment that copying tends to occur.
Personally, I would just use a sequence reference. You'll have to manually dereference it occasionally (using the [] operator) but its the most flexible solution. You could also just store the sequence as part of a larger reference type too.
Alternatively, you could try rolling your own collection using the memory allocation functions and unchecked pointers.
When a shallow sequence is resized, only the variable currently being modified has its reference updated; the other variables will still have references to the old data.
proc foo() =
var a = newSeqOfCap[int](2)
a.add(1)
var b: seq[int]
shallowCopy(b, a)
a[0] = 0 # modify sequence after copying
a.add(2) # further modification
echo a
echo b
b.add(3) # resizing happening here!
echo a
echo b
foo()
This outputs:
@[0, 2]
@[0, 2]
@[0, 2, 3]
@[0, 2, 3]
Modifications after shallow copying are fine because strings and seqs are garbage-collected heap-objects. As you see, both references are still fine after resizing happened.
@flyx Are you quite sure the sequence is being resized? When I modify the code a bit, I get some very strange results:
foo() =
var a = @[0, 0]
a.add(1)
var b: seq[int]
shallowCopy(b, a)
a[0] = 0 # modify sequence after copying
a.add(2) # further modification
echo a
echo b
for i in 0..20:
a.add(i)
echo a
echo b
foo()
This produces:
@[0, 0, 1, 2]
@[0, 0, 1, 2]
@[0, 0, 1, 2, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
@[2318280822927416128, 3184080310742559793, 3683993088988819744, 2318286320485802028, 3186332110640131126, 2318280895740262688, 2318283094763516209, 2318285293786772273, 2318287492810028337, 2318289691833284401, 26230164680358193, 0, 0, 0, 0, 11, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
I did not read everything here, but my knowledge so far is, that seq and string is in fact a garbage collected heap object, despite the fact that semantically it is almost identical to the c++ std::string and the c++ std::vector. So copy on assignment etc. This is according to my information done, because it was the easiest way to implement it. But Araq is working at the moment to optimize the seq and string type to me non-nil able. That could also mean that they won't require garbage collection anymore, but that is just an assumption.
And for returning values. Nim could theoretically behave exactly like c++, meaning that a return would be implemented as a move operation. A move is much more like a shallow copy, but it invalidates the source it gets moved from. so sot the entire vector is copied, just the header that is pointing to the data. This is possible, because it is known that the result from the function scope is destroyed after return. that means the outer object can take over the ownership of the data from the result value.
Then there is another possible optimization. When the function gets inlined, the the local object is initialized directly at its final destination. So not even a move needs to be performed anymore. There is no need to explicity use the result value, because everything can be converted to use the result value, the result value just makes it a bit easier to understand how a function would behave when it is inlined.