Lifetimes

Lifetimes are not something you have to worry about in Heck, but I think by making error messages straightforward, a smart enough compiler could make dealing with lifetimes pretty hecking intuitive, and if raw references are used with compile-time checks, your code will be much faster than it would be if it used smart references, which are reference counted.

Obviously, there's the basic example with two scopes, commonly used to demonstrate rust lifetimes:


let r: int&

{
    let x = 5
    r = &x // compiler error: x does not live long enough
}

// do stuff with r blah blah blah

but it gets a lot smarter than that:

class A {

    let r: int&

    func setR(int& r) {
        this.r = r
    }

}

let a = A() // this is stored on the stack btw

{
    let x = 5
    
    a.setR(&x) // compiler error: x does not live long enough

}

In the above code, the object "a" cannot hold a reference to x because its lifetime is longer than that of "x," so the compiler will give you an error. Obviously, if "x" was declared in the same scope as "a," there would be no error.

The Rust compiler is smart enough to do this, but it requires you to explicitly declare the lifetimes before you can use them, whereas Heck does this implicitly, because when it compiles the "setR()" function, the compiler knows that the reference must live at least as long as the class member "r."

There are some edge cases that haven't fully been flushed out, which might require you to be more explicit about the lifetimes, which will always be an option. As you might have read earlier, Heck tries to do as much as possible implicitly, without overstepping its bounds (e.g. it can't guess the most likely thing you would want to do, it has to KNOW), but you will always be able to be more explicit if you want.

The most important "edge case" that you might run into involves polymorphism:

class A {

    let r: int&

    func setR(int& r) {
        this.r = r
    }

}

let r = 5

class B: A { // child class of A

    func setR(int& r) {
        global.r = r // maybe a questionable practice, but valid nonetheless
    }

}

// new scope
{

    let x = 5

    // a1 could be a reference to either an A or B object
    let a1 = random() > 0.5 ? new A() : new B() // "new" creates a smart reference

    a1.setR(&x) // compiler error: x does not live long enough

}

It may look tricky for the compiler to figure out because "a1" could be a reference to either an A or B object. When dealing with a reference to an abstract class, the compiler should assume the worst, which would be a "B" object in this case. It's also important to note that since only references can be polymorphic, you could for example create an "A" object on the stack and a call to "setR(&x)" would compile since the compiler knows the A object is in fact an A object.

Another tricky situation involves callbacks, and your two options are to either keep track of every possible function that could be called or to force the user to specify the lifetimes of a callback parameters explicitly. The first option would be very difficult to implement, so its possible that the second option will be the default in the initial release of the Heck compiler.

Both of these edge cases might not be able to be dealt with implicitly at all if there is some kind of oversight, so no promises.

Anyways, all of this is just a possibility, and might not make the final cut, but raw references that the compiler can perform safety checks on seems essential if you want to write efficient code.

I have made a few more decisions for how different types of references will be used, which can be seen in this example:

let x = 5 // regular variable stored on the stack

let r1 = &x // regular raw reference to x

let r2 = new int(6) // smart reference to a variable on the heap (reference counted)

let r3 = new unique int(7) // unique smart reference, akin to C++'s "unique_ptr"

// now, if you wanted to pass the values of these references to functions, you can dereference all of them like so:
func addTwo(x: int) {
    return x + 2
}

print(addTwo(*r1)) // prints 7

print(addTwo(*r2)) // prints 8

print(addTwo(*r3)) // prints 9

// maybe explicit dereferencing won't be required, similarly to C++ references

// now, what if you wanted to make a function that takes arguments by reference, but accepts all reference types?
func addThree(x: int&) {
    return *x + 3
}

print(addThree(*r1)) // prints 8

print(addThree(*r2)) // prints 9

print(addThree(*r3)) // prints 10

Unfortunately, the "addThree" might not be a universal solution at this point. Maybe if the smart references were immutable it would be safe, but smart references are actually more dangerous in this case, because they make it possible to use a reference to an object after it dies.

This is because regular objects that are stored on the stack always have the same lifetime, so the compiler can easily perform safety checks on raw references to them. On the other hand, the objects owned by smart references can die at any time, so if a smart reference owns an object, and a raw reference to that object is passed to a function, that function might somehow reassign the value of the smart reference, either directly or indirectly, which could destroy the object that the raw reference points to.

This means there is only one option, which is to only allow raw references to be taken from smart references if they are immutable. Of course, Rust has a lot of ideas to borrow from, pun intended, but Heck is supposed to be more akin to higher level languages like Python unless low level control is explicitly requested. Fortunately, there are better, higher-level solutions, which I will explain a bit more in my next post.

Speaking of higher level, there is always the lazy option, which is to just use generics like so:

func addFour(x) {
    return *x + 4
}

In this case, a different version of the function would be generated for each type of reference. I wouldn't advise this option over the immutable smart reference solution, but it would be just as fast, only with more code generated. Maybe generic types could have a feature that forces the parameter to be some kind of reference of a certain type, so it would have to be some kind of reference to an int for example, which would be much more clean. I'm happy with either solution for the initial alpha release.

Comments

Popular Posts