Note: None of this has been implemented.
Values are copied as they flow from variable-to-variable or function-to-function. Changes made to a copy don't affect the original value.
imm a = 3 mut b = a // b holds a copy of a's value b += 1 // b's value is changed to 4 a // is still 3
Sometimes, however, we have values for which we don't want multiple copies to exist at the same time. Such "non-copyable" values can be useful:
- When the value depends on a stateful resource(s), where the existence of a copy of this value would confuse how we interact with the resource(s). For example, imagine the value is a handle to a just-opened file and we make a copy of that handle as another value. If we close the file using one handle, it might then be unsafe to use the other handle.
- When it is not clear how to create a faithful copy of a complex value, particularly when insufficient metadata exists or when doing so involves memory or resource allocation or establishing appropriate external dependencies.
- When we want to take advantage of the single-owner memory management strategy. If an allocated value never needs to be copied, single-owner offers performance and determinacy advantages over reference-counting and tracing GC.
- When we want to transfer mutable data (including complex data graphs) from one thread to another, without requiring the use of performance-degrading locks or atomics.
To provide safe support for such "non-copyable" values, Cone supports move semantics. Move semantics enable non-copyable values to be moved from one place to another in a code's logic. However, once the value moves, it may no longer be accessed from its old location. This restriction ensures there is only ever one copy of the value in existence.
Move vs. Copy Types
Whether a value is handled by move vs. copy semantics is determined by its type. In general, Cone treats most types as having copy semantics, unless it detects some reason why move semantics restrictions are needed. Here are the rules:
- If the type declares that it uses move semantics, then it does. This is as simple as
adding the @move attribute on the type declaration:
struct @move Handle: id i32
- If the type implements an initializer method that essentially enables the creation of a copy, the type's values have copy semantics. Since the compiler automatically uses this cloning method (when implemented) to make all copies of values of this type, these values are clearly copyable.
- If the final method is implemented, the type's values have move semantics. If the type requires a finalizer to clean up dependencies, chances are excellent that creating copies will open the door to safety issues.
- If any of the type's fields have move semantics, the type's values have move semantics. This is because if part of a value cannot be copied, the whole value certainly cannot be either.
- If any of the type's fields are references to other data structures, the type's values have move semantics. This is because it is not immediately obvious how to automatically and safely create copies of this type's values.
- If the type is marked as having move semantics, that's what it gets.
- If none of the above apply, the type's values have copy semantics.
Move Capabilities and Restrictions
Even when we want to prohibit the copying of these values, we still want to be able to move such values around from variable-to-variable or function-to-function.
Moving values around looks just like copying, but adds restrictions to ensure multiple copies of the value are never made. These restrictions, if not well understood, can make it frustrating to write programs that do what is desired in a way that the compiler deems acceptable.
It would be misleading to think that moving a value does not copy it. In fact, a "shallow" copy of the value might well be made on every move. What the move restrictions guarantee is that only one version of the value ever exists at a time.
This guarantee is implemented by deactivating the value's source as part of the move. When the source is a variable, this prohibits post-move access to that variable's value:
imm socket = Socket() // Open a new network socket (move semantics) imm sock2 = socket // Move the value to sock2 socket.poll() // **ERROR** socket no longer has an accessible value
By de-activating use of the variable socket after the move, we effectively have only one "copy" of its value, which is now in sock2. If we had not deactivated socket, we would then have two copies of a non-copyable value.
The same deactivation mechanism applies when passing a non-copyable value to another function.
imm socket = Socket() // Open a new network socket (move semantics) afunction(socket) // the socket is moved to the called function anotherfunc(socket) // **ERROR**: socket is deactivated and unusable
Deactivation of the source happens even for conditional moves, where one branch of conditional move the value but another does not. When the branches join together again, the compiler can't know whether the value is there or not, so conservatively, it assumes the value is gone.
A non-copyable value may be returned by a called function. There is no need to deactivate the value's source, since it is already gone.
(Note: A deactivated source variable may be re-activated at any point by giving it a new value.)
Scope-surfing and Destruction
This movement of a non-copyable value can be understood as shortening or lengthening the value's lifetime as it surfs from one scope to another. When it fails to surf out of its last scope, the value will be destroyed at the end of that scope, as it is no longer useful.
To destroy a move value more quickly, just assign it to the anonymous variable "_":
imm socket = Socket() // Open a new network socket (move semantics) _ = socket callfn(socket) // **ERROR**. socket is deactivated
Doing this on a copyable value has no effect.
If we want to move a non-copyable value out of a struct or array, things get more complicated, as other values are now potentially affected by such a move:
- A non-copyable value held by a named field in a struct may be moved out. However, doing so invalidates any future access to the variable holding that struct, including any values held by its other fields.
- It is prohibited to move out a non-copyable value held by some indexed element in an array.
These are significant limitations on the use of non-copyable values in structs and arrays. However, these limitations can be ameliorated by using either the swap or left-assignment operator:
The swap operator may be used on copy or move values:
struct Connection: s Socket b i32 mut conn = Connection[Socket(), 10] mut x = Socket() // Open another socket conn.s <=> x // conn now contains the new socket and x contains the old one
Swapping offers a helpful way to get around restrictive moves. One can temporarily extract a 'move' value out of a field or array element by swapping it with some arbitrary type-compatible value. After making use of the extracted value, simply swap it back in.
The reason why this works is that we end up with no copies of either value and the source still has a valid value (even if it is only a dummy value). That ensures that the original source stays safe to use.
Assignment is an expression whose value (for copy semantics) is a copy of the value of the right-hand expression. With move-only semantics, it can be very helpful to have the value of the assignment expression be the value previously stored in the left-hand (lval) memory location. To obtain this result, use the left-assignment operator (:=).
mut conn = Connection[Socket(), 10] mut x = conn.s := Socket() // conn now contains the new socket and x contains the old one