Lifetimes and References: Follow Up
In my last post, I mentioned some issues with lifetimes and references. I mentioned some possible solutions, but I just had a breakthrough after putting a couple things together.
First of all, I decided on something I kind of teased at earlier, which was making regular references work more like C++ references. This means that you can pass pretty much anything to a function with a regular reference in its signature:
It looks very simple, almost like a scripting language, but it's more complicated under the hood, and as you might expect, it's hecking fast!
The first call just takes the reference of the variable. This is nothing that hasn't been done before, in fact, this is a textbook example of how you use references in C++, and compared to raw pointers, it's proven to be extremely useful. All that's really happening is the compiler passes a reference to the variable "num" to the function.
In the second call, we manually pass a reference to "num." This works perfectly like before, we just had to take the reference itself. This is extremely useful because you only have to write one function and you can deal with two different types. Heck supports generics, but this is actually better than generics because the compiler doesn't have to generate as much code.
Here's where things get more interesting. As I have stated multiple times, Heck is supposed to be able to figure out what you're trying to do at compile time, but the important thing here is that it only does something if it KNOWS what you're trying to do. This is very important, because we have to be able to know what our code does when we read and write it. This third call is a great example of this. Heck aims to be high-level by default, but it also aims to be efficient and run closer to C-level performance by default, and when you really optimize you should be able to actually achieve the kind of performance you get in C pretty much 100% of the time.
Smart references are not something you would typically see in C, however, but they're not too expensive. Shared references, which are reference counted and simply called "references" in most high-level languages, are pretty much a necessity if you want to program in the same way you would in those kinds of languages. Because of this, I decided to make shared references very easy to create, and I followed in the footsteps of most modern languages, which often use the "new" keyword to do this. Java, JavaScript, and C# are all high-level examples of this, and even lower-level languages at least allocate something on the heap with "new," so I decided that the "new" keyword allocates an object on the heap and returns a shared reference, but creating objects and other variables without "new" will just put them on the stack. I think this is very intuitive, and it encourages the use of the stack most of the time.
You'll probably still want to pass items on the stack by reference, which is very easy and quick as shown above. What is also shown above is the use of a shared pointer being passed to the "addTwo" function as the same reference type. On the surface, it looks like this would be totally fine, but since shared references are mutable by default, it's possible for that reference to be reassigned somehow before the "addTwo" function is finished running, depending on what "addTwo" does. Obviously, the way things are in the code above, "addTwo" isn't going to reassign our shared reference "r2," but it could, and if it's the only instance of a shared reference to that object, the object will get deleted and the raw reference that got passed to the function will no longer point to valid memory.
But like I said earlier, Heck is supposed to do what you expect from it, and based on what we know about shared references, they aren't supposed to break like this; they're supposed to be memory-safe. In Java, Python, and many other high-level languages, you'd expect any reference passed to a function to live long enough for its use, so that's precisely what we have to make sure happens.
You cannot cast a mutable smart reference to a raw reference safely, but what you can do is make an immutable smart reference that points to the same object, and if you wanted it to look nice, you'd probably do something like this:
This is a really neat solution because the function "createConstRef" will return a reference that is both immutable and will live long enough to be passed to the function. This is how the first code snippet works; the compiler will automatically create an immutable shared reference for us that will make sure the object it's referencing lives long enough. This also does what you'd expect, because in languages like Java, you would also be making a copy of the reference, the only difference here is that inside of the function body it's used as a raw reference, which means it no longer behaves like a smart reference, so we can't expect it act like one.
When you pass a mutable shared reference to a function as a raw reference, the raw reference cannot be used past the function's lifetime. It can be passed to other functions, but it will still be bound by the same lifetime. This is pretty straightforward though, because the function definition clearly states that a raw reference is used, and if the function needs the reference to live longer than the mutable shared reference can guarantee, all we need is an informative error message and we have something that's just about as intuitive as a scripting language but much faster. If this error does occur, most of the time the user will be able to simply make the unique reference const, but if they really need to, they can take an entirely different approach depending on the situation.
This isn't messy code because of a few language guidelines that will eventually be set in stone somewhere. These guidelines dictate that lifetime is the responsibility of the programmer who writes the function, who will be able to make informed decisions based on the purpose of the function, and will be guided by informative error messages along the way if need be.
As I stated in my last post, programmers will be able to explicitly declare lifetimes if they have to. This functionality will be based on some of Rust's features, because after all, sometimes it's better to add some more rules to your program, especially as it gets more complicated.
For now, the only thing left to address is the final function call in the first code snippet. This is a unique reference, which is similar to a "Box<T>" in Rust. The only problem is that these can't be safely used in the same way because we can't make a new smart pointer to extend its lifetime, since it isn't reference counted. We could do some kind of a "borrow," where we transfer the value to a temporarily immutable unique reference, but then we would have to make the original unique reference empty, which means we would have to adress whether or not Heck will support null references.
Most of the time I lean towards what most programming languages do rather than completley going the Rust route. Heck uses a lot of tricks to try to be completley memory safe, and as you'll see later, surprisingly, thread safe as well, while still behaving like the majority of programming languages with far less restrictions. Null pointers would be way less of a problem than lifetime issues, but I still prefer to avoid them, so I think, at least in the initial release, Heck will aim to use optional types instead, and if whatever community my language ends up attracting decides null pointers are the way to go, I may reconsider.
Anyways, most of the stuff I've been saying about lifetimes won't be a problem most of the time, these are edge cases, and you'll certainly be able to use immutable unique references when you need to, and if you can't, you can either put up with the optional, more strict, Rust-like rules, or you can switch to shared references, which will end up being encouraged far less than you'd probably think, especially if you reconsider the first two function calls.
There is a plan for thread safety as well. Many of the rules Rust has aren't for memory management, but for thread safety instead, which can be really annoying if you aren't writing a multithreaded program. The gist is that threads will be a little more baked into Heck than they are in Rust, which will allow for more flexibility. I think you'll be surprised. If you noticed that these last two posts have neatly syntax-highlighted code, you should check out my GitHub repository for it, which uses highlight.js to generate static HTML for this blog, and you can even dynamically add syntax highlighting to your site it you want, and I tried to make it really simple.
It'll probably be a while before I make another post, but then again I don't think anybody follows this blog right now. Either way, if you made it this far, thanks for reading :)
First of all, I decided on something I kind of teased at earlier, which was making regular references work more like C++ references. This means that you can pass pretty much anything to a function with a regular reference in its signature:
func addTwo(x: int&) {
return x + 2 // implicit dereference
}
// now you can pass anything to addTwo, as long as it's an integer:
let num = 1 // on the stack
print(addTwo(num)) // prints 3
let r1 = &num // reference
print(addTwo(r1)) // prints 3
let r2 = new int(2) // smart reference to thing int on the heap
print(addTwo(r2))
let r3: const shared int = new int(3) // experimental syntax for immutable shared reference
print(addTwo(r3))
let r4 = new const unique int(4) // unique smart reference
print(addTwo(r4))
It looks very simple, almost like a scripting language, but it's more complicated under the hood, and as you might expect, it's hecking fast!
The first call just takes the reference of the variable. This is nothing that hasn't been done before, in fact, this is a textbook example of how you use references in C++, and compared to raw pointers, it's proven to be extremely useful. All that's really happening is the compiler passes a reference to the variable "num" to the function.
In the second call, we manually pass a reference to "num." This works perfectly like before, we just had to take the reference itself. This is extremely useful because you only have to write one function and you can deal with two different types. Heck supports generics, but this is actually better than generics because the compiler doesn't have to generate as much code.
Here's where things get more interesting. As I have stated multiple times, Heck is supposed to be able to figure out what you're trying to do at compile time, but the important thing here is that it only does something if it KNOWS what you're trying to do. This is very important, because we have to be able to know what our code does when we read and write it. This third call is a great example of this. Heck aims to be high-level by default, but it also aims to be efficient and run closer to C-level performance by default, and when you really optimize you should be able to actually achieve the kind of performance you get in C pretty much 100% of the time.
Smart references are not something you would typically see in C, however, but they're not too expensive. Shared references, which are reference counted and simply called "references" in most high-level languages, are pretty much a necessity if you want to program in the same way you would in those kinds of languages. Because of this, I decided to make shared references very easy to create, and I followed in the footsteps of most modern languages, which often use the "new" keyword to do this. Java, JavaScript, and C# are all high-level examples of this, and even lower-level languages at least allocate something on the heap with "new," so I decided that the "new" keyword allocates an object on the heap and returns a shared reference, but creating objects and other variables without "new" will just put them on the stack. I think this is very intuitive, and it encourages the use of the stack most of the time.
You'll probably still want to pass items on the stack by reference, which is very easy and quick as shown above. What is also shown above is the use of a shared pointer being passed to the "addTwo" function as the same reference type. On the surface, it looks like this would be totally fine, but since shared references are mutable by default, it's possible for that reference to be reassigned somehow before the "addTwo" function is finished running, depending on what "addTwo" does. Obviously, the way things are in the code above, "addTwo" isn't going to reassign our shared reference "r2," but it could, and if it's the only instance of a shared reference to that object, the object will get deleted and the raw reference that got passed to the function will no longer point to valid memory.
But like I said earlier, Heck is supposed to do what you expect from it, and based on what we know about shared references, they aren't supposed to break like this; they're supposed to be memory-safe. In Java, Python, and many other high-level languages, you'd expect any reference passed to a function to live long enough for its use, so that's precisely what we have to make sure happens.
You cannot cast a mutable smart reference to a raw reference safely, but what you can do is make an immutable smart reference that points to the same object, and if you wanted it to look nice, you'd probably do something like this:
func addTwo(x: int&) {
return x + 2
}
func createConstRef(r: shared int) {
return const shared int(r)
}
...
let r2 = new int(2)
print(addTwo(createConstRef(r2))) // prints 4
This is a really neat solution because the function "createConstRef" will return a reference that is both immutable and will live long enough to be passed to the function. This is how the first code snippet works; the compiler will automatically create an immutable shared reference for us that will make sure the object it's referencing lives long enough. This also does what you'd expect, because in languages like Java, you would also be making a copy of the reference, the only difference here is that inside of the function body it's used as a raw reference, which means it no longer behaves like a smart reference, so we can't expect it act like one.
When you pass a mutable shared reference to a function as a raw reference, the raw reference cannot be used past the function's lifetime. It can be passed to other functions, but it will still be bound by the same lifetime. This is pretty straightforward though, because the function definition clearly states that a raw reference is used, and if the function needs the reference to live longer than the mutable shared reference can guarantee, all we need is an informative error message and we have something that's just about as intuitive as a scripting language but much faster. If this error does occur, most of the time the user will be able to simply make the unique reference const, but if they really need to, they can take an entirely different approach depending on the situation.
This isn't messy code because of a few language guidelines that will eventually be set in stone somewhere. These guidelines dictate that lifetime is the responsibility of the programmer who writes the function, who will be able to make informed decisions based on the purpose of the function, and will be guided by informative error messages along the way if need be.
As I stated in my last post, programmers will be able to explicitly declare lifetimes if they have to. This functionality will be based on some of Rust's features, because after all, sometimes it's better to add some more rules to your program, especially as it gets more complicated.
For now, the only thing left to address is the final function call in the first code snippet. This is a unique reference, which is similar to a "Box<T>" in Rust. The only problem is that these can't be safely used in the same way because we can't make a new smart pointer to extend its lifetime, since it isn't reference counted. We could do some kind of a "borrow," where we transfer the value to a temporarily immutable unique reference, but then we would have to make the original unique reference empty, which means we would have to adress whether or not Heck will support null references.
Most of the time I lean towards what most programming languages do rather than completley going the Rust route. Heck uses a lot of tricks to try to be completley memory safe, and as you'll see later, surprisingly, thread safe as well, while still behaving like the majority of programming languages with far less restrictions. Null pointers would be way less of a problem than lifetime issues, but I still prefer to avoid them, so I think, at least in the initial release, Heck will aim to use optional types instead, and if whatever community my language ends up attracting decides null pointers are the way to go, I may reconsider.
Anyways, most of the stuff I've been saying about lifetimes won't be a problem most of the time, these are edge cases, and you'll certainly be able to use immutable unique references when you need to, and if you can't, you can either put up with the optional, more strict, Rust-like rules, or you can switch to shared references, which will end up being encouraged far less than you'd probably think, especially if you reconsider the first two function calls.
There is a plan for thread safety as well. Many of the rules Rust has aren't for memory management, but for thread safety instead, which can be really annoying if you aren't writing a multithreaded program. The gist is that threads will be a little more baked into Heck than they are in Rust, which will allow for more flexibility. I think you'll be surprised. If you noticed that these last two posts have neatly syntax-highlighted code, you should check out my GitHub repository for it, which uses highlight.js to generate static HTML for this blog, and you can even dynamically add syntax highlighting to your site it you want, and I tried to make it really simple.
It'll probably be a while before I make another post, but then again I don't think anybody follows this blog right now. Either way, if you made it this far, thanks for reading :)
Comments
Post a Comment