Closed yorickpeterse closed 1 year ago
Passing references around may require additional increments. For example:
def store(value: ref String) {}
let a = 'foo'
let b = ref a
store(a)
store(b)
If store()
were to store the ref somewhere (e.g. in an Array that outlives store
), each store requires an additional increment. Without this, dropping b
(which would decrement the ref count) would result in an incorrect ref count (= not enough).
This probably requires some form of escape analysis to determine if a ref T
escapes, and if so increment it automatically.
In case I forget about the link/name, the implementation here is based on the paper https://researcher.watson.ibm.com/researcher/files/us-bacon/Dingle07Ownership.pdf.
In this MR, Option
uses NULL as the None signal. This doesn't work when a None
is copied to another process, as this fails when checking if the NULL pointer is a permanent pointer. The use of NULL here was a quick hack to get rid of the singleton/global, but we need something better. Getting rid of this also removes the need for all the some?
checks in the current Option
implementation.
To solve this, we need to revisit turning Option
into a trait, with two classes (Some
and None
) implementing it. The None
class would be generic (None!(T)
) but not actually use the type parameter. This way we can treat it like a Option!(T)
. This requires some type inference changes to support code like this:
x.if(true: { None.new }, false: { Some.new(10) })
Currently the inferred return type would be Option!(T)
instead of Option!(Integer)
.
We also need to turn Equal
into a generic trait. This way we can define Option
like so:
trait Option!(T): Equal!(Option!(T)) {
...
}
If we keep Equal as-is, it requires the operand to be of type Self
. When implementing the trait, that translates to the class implementing it. This means that for a Some
, you'd only be able to compare it with another Some
. But at the trait level Equal
would expose ==(Self)
, translating to ==(Option!(T))
. This could then result in a ==
implementation being defined for type A, while it may also be given type B.
Variable assignments should return the old value, not the new value. We'll need to load these values anyway to drop/decrement them. Returning them (and dropping them if unused) makes an attribute swap trivial:
class Container!(T) {
@value: T
def swap(with: T) -> T {
@value = with
}
}
Meanwhile in Rust you'd have to do something like this:
use std::mem::swap;
pub struct Container<T> {
value: T,
}
impl<T> Container<T> {
pub fn swap(&mut self, mut with: T) -> T {
swap(&mut self.value, &mut with);
with
}
}
The new destructuring syntax must be documented:
import std::pair::Pair
let [n1, n2] = Array.new(10, 20, 30)
let [b1, b2] = ByteArray.new(10, 20, 30)
let { a = @first, b = @second } = Pair.new(10, 20)
n1 # => 10
n2 # => 20
b1 # => 10
b2 # => 20
a # => 10
b # => 20
For closures we need some form of escape analysis to determine if return
, throw
, and yield
can be used. We can't limit this to closure references (ref do
), as this complicates development. For example, if you take a closure you now have to decide: should it be taken by value (do
), or by reference (ref do
)? Most of the time it's not clear what you should do.
In addition, passing the closures by reference would not allow the use of move do
(= one-shot closures), as you can't move out of a ref move do
. This then prevents closures from moving outer variables without a reassignment or return
. With move do
that isn't needed, and we just disallow use of the moved variable.
In other words, what a closure can do (throw
, return
, etc) isn't tied to how its passed around. Instead, it's tied into whether it outlives it's enclosing scope or not. Decoupling this also makes it easier to capture references and move the closure around, as long as we can guarantee the closure won't outlive those references (as much as possible at least, as we can still fall back to a runtime panic when there are dangling references).
For Option
, I'm still unhappy with Option.let
and Option.let_ref
. The pattern "do something if it's a Some" is quite common. In Rust you can do something like if let Some(x) = option { ... }
, which let
and let_ref
try to emulate. The issue here is that I just can't get over the name let_ref
, especially since using let_ref
is more common compared to let
. On the other hand, it's consistent with as_ref
and get_ref
. Maybe I just need to get used to it.
We currently use various ToX
traits and to_x
methods to convert types. This doesn't work well in this new setup, as these traits take their receiver by reference and return a new type. For many types (e.g. ToPath
) that means having to clone data. For example, if you want to take a file path you'd use ToPath
, so you can accept both a String
and a Path
. Ideally no cloning is performed if the input already is a Path
.
With ToPath
in its current state this isn't possible, so we'll end up creating a redundant Path
clone. To solve this, we need to introduce IntoX
traits. These are similar to their ToX
counterparts, except they take ownership of the receiver. If Path
implements IntoPath
, its implementation can simply take the receiver by value and return it; removing the need for any clones. If the input is a String
, the user can either convert that to a Path
themselves or just give up ownership of the String
. This leads to the following trait implementations:
trait IntoPath {
move def into_path -> Path
}
impl IntoPath for Path {
move def into_path -> Path {
self
}
}
impl IntoPath for String {
move def into_path -> Path {
Path.new(self)
}
}
Sketch code for a potential Option
setup:
# TODO: to allow implementing of Equal, we need to make it generic. This way a
# Some can be compared with a None.
trait Optional!(T) {
def truthy? -> Boolean
move def get -> T
move def get_or(default: T) -> T
move def get_or_else(block: move do -> T) -> T
}
class Some!(T) {
@value: T
static def new!(T)(value: T) -> Optional!(T) {
Some { @value = value }
}
}
impl Optional!(T) for Some {
def truthy? -> Boolean {
True
}
move def get -> T {
@value
}
move def get_or(default: T) -> T {
@value
}
move def get_or_else(block: move do -> T) -> T {
@value
}
}
class None!(T) {
static def new!(T) -> Optional!(T) {
Self {}
}
}
impl Optional!(T) for None {
def truthy? -> Boolean {
True
}
move def get -> Never {
panic("A value can't be obtained from a None")
}
move def get_or(default: T) -> T {
default
}
move def get_or_else(block: move do -> T) -> T {
block.call
}
}
added 1 commit
added 1 commit
added 1 commit
added 1 commit
added 1 commit
added 1 commit
Merges single-ownership -> master
This MR drastically changes the way Inko works, how memory is managed, the memory layout, etc. It's too much to cover in this MR, instead I'll cover it in the release notes. But in short: Inko will use a form of single ownership like Rust, but a more flexible version of it. For concurrency we're borrowing some ideas from Pony, but again in a more flexible/accessible manner.