An example of providing a resource in Rust
The owner of a resource stores the handle in a part of its data structure. The handle is enough to communicate with the resource manager. So the owner doesn't want this handle to be used by any other part of the application, because then any other part of the application might for instance tell the resource manager that the resource is not needed anymore. For this reason, the handle is stored in a part of the data structure of the owner that can only be accessed by code that is associated directly to the owner. You can think of it as being a private field.
Annotated code example
TODO: Find a better example than files.
Phew, that was a lot of theory. Time to illustrate this with an example! Rust's file system API is a little bit too complicated to work as an introduction example, so let's consider a simplified version. Don't worry if you don't understand everything, we will explain every concept (related to references and borrowing) in detail further on.
mod FileSystem {
pub struct File {
file_descriptor: u64
}
// Incomplete code, to be continued.
Here we start a new module, named FileSystem
.
The module acts as a boundary that restricts access to data types, fields, and
functions.
In the module, we declare a new data type, named File
.
Remember that Rust is a typed language.
The data type is a struct, which means "something with fields".
The pub
keyword before struct
indicates that
the struct is public, which means that it can be used outside the module
FileSystem
in which it is defined.
The struct has one field, called file_descriptor
.
This field will be used to hold the handle inside an u64
.
There is no pub
keyword before the name of the field, so the field can
only be accessed from within the module FileSystem
in which it is defined.
In other words, it's a "private" field.
An impl
block associates data to a given type.
// Still in the module `FileSystem`.
impl File {
pub fn open(filepath: &FilePath) -> File {
// `ask_operating_system_for_file_descriptor` will be defined later
let file_descriptor
= File::ask_operating_system_for_file_descriptor(filepath);
return File{file_descriptor: file_descriptor};
}
}
// Incomplete code, to be continued.
We define a function called open
that takes a parameter called filepath
.
The ampersand before the type FilePath
indicates that filepath
is a
reference to a value of type FilePath
itself, rather than a value of type
FilePath
. (We omit the declaration of the type FilePath
.)
The function returns a value of type File
. For simplicity, there
is no error handling and we just let the program crash by calling
panic!
. Of course, the the standard library has proper error handling.
The pub
keyword before the name of the function indicates that the function
is public, in other words, it can be called from outside the module
FileSystem
in which it is defined.
Internally, the function calls the function
File::ask_operating_system_for_file_descriptor
, which we will define below.
The statement return File{file_descriptor: file_descriptor};
then constructs a value of type File
by specifying all its fields and
returns this.
Note that because the field file_descriptor
is only accessible from within
the module, this way of constructing a value of type File
is only available
within the FileSystem
module.
// Still in the `impl` block.
fn ask_operating_system_for_file_descriptor(filepath: &FilePath) -> u64{
unsafe {
// the internals of a type that communicates with a resource
// manager are typically unsafe.
// We omit it here.
}
}
// Incomplete code, to be continued.
Usually, you don't have to communicate with a resource manager directly, you use a function from the standard library to do the communication for you. It's usually interfacing with an external API, I have left it out because it's not the focus of this document.
Note that the function ask_operating_system_for_file_descriptor
is not marked
pub
, so it can only be called from within the module.
In the function open
, we already saw how it can be called.
Note the syntax File::ask_operating_system_for_file_descriptor
.
Because the function ask_operating_system_for_file_descriptor
is defined in
the impl
block of the type File
, you have to specify the type, even when the function
does not have a parameter of type File
or return a value of type File
.
The function could also be defined as a stand-alone function outside of the
impl
block, this would not make a difference.
// Still in the `impl` block.
pub fn read_byte(&self, index: usize) -> u8 {
unsafe {
// Yep, more unsafe stuff, which I omit.
}
}
The &self
parameter is special.
It means that the first parameter, with name self
is of type &File
(because
we are in the impl File
block).
Type &File
means an immutable reference to a value of type File
.
This implies that any borrower that borrows a value of type File
as immutable
has access to read bytes from the file.
It also allows a special syntax: if file
is a variable of type File
or of type
&File
or of type &mut File
, then you can write file.read_byte(3)
to
read the third byte from the file.
To simplify the matter, we assume that we will simply crash the current thread when we have no access to read from the file, when we try to read past the end of the file etc. Of course, the real Rust API handles errors in a better way.
Because the field file_descriptor
is not accessible from outside the
FileSystem
module, this is the only way a user of the code can read bytes
from the file.
// Still in the `impl` block.
pub fn file_size_in_bytes(&self) -> usize {
unsafe {
// Also omitted.
}
}
// Incomplete code, to be continued.
This function is similar to the read_byte
function.
It also takes an &self
parameter, which allows it to be used by any value
that borrows the value of type File
as immutable.
// Still in the `impl block.
pub fn write_byte(&mut self, index: usize, byte_to_write: u8) {
unsafe {
// Omitted.
}
}
} // Close the impl block. Incomplete code, to be continued.
This function takes an &mut self
parameter.
It indicates that this function is restricted to references as mutable only.
References as immutable cannot be passed to this function (because they have
a different data type).
In this way, access to write a byte to a file is restricted to references as mutable.
So you can only write to a file if you borrow it as mutable, not as immutable.
It is very important to not use a resource after it has been released.
Rust automatically inserts calls to release a resource.
This is done by calling the drop
function from the Drop
trait.
Let's see how the file File
ensures that the file is closed properly.
// Still in the `FileSystem` module
impl Drop for File {
fn drop(&mut self) {
unsafe {
// Again, this is unsafe code.
}
}
}
} // End of the `FileSystem` module.
Here we see the syntax impl Drop for File
is used to indicate that the
File
struct implements the Drop
trait.
The implementation can be found in the block immediately below.
The Drop
trait defines only one function, called drop
that takes one
&mut self
parameter.
The implementation is again typically unsafe code that does communication
with an external API offered by the operating system.
What happens here is that the operating system is instructed to close the
file with the file_descriptor
that was required during the initialization.
Let us now see how the File
struct can be used in practice.
We assume that the file struct is used outside of the FileSystem
module.
Let us write a completely inappropriate method to copy data from one file to
another.
Let us first write the scaffolding of opening the files, calling another
function to copy the data, and closing the files.
// Outside the FileSystem module.
fn copy_file(source_filepath: &FilePath, destination_filepath: &FilePath) {
let source_file = File::open(source_filepath);
let mut destination_file = File::open(destination_filepath);
copy_data(&source_file, &mut destination_file);
}
// To be continued...
On the first line of the function, we open the source file.
The value is immutable by default.
Note the File::open(...)
syntax for calling a method associated to a type.
On the second line, we open the destination file.
This value is defined as mutable.
The third line is very interesting.
Here we call the copy_data
function (that is still to be written).
This function borrows the source_file
as immutable and it borrows the
destination_file
value as mutable (that's what the mut
after the &
indicates).
Note that we do not write drop(...)
, the compiler automatically inserts this
call for us.
Maybe it's time to let this sink a little, because this illustrates the foundation of Rusts resource management.
OK, on to the function that copies data.
As we already noted, this function borrows source_file
as immutable and
destination_file
as mutable.
// Still outside the FileSystem module.
fn copy_data(source: &File, destination: &mut File) {
let length = source.file_size_in_bytes();
for index in 0 .. length {
let byte = source.read_byte(index);
destination.write_byte(index, byte);
}
}
The signature clarifies that source
is borrowed as immutable from somewhere
and destination
is borrowed as mutable.
When calling the file_size_in_bytes
function, we use a syntax we didn't use
before.
An equivalent, but not as beautiful syntax would be
let length = File::file_size_in_bytes(source);
.
Instead of passing the &self
parameter explicitly,
we use the dot-operator to call the function as is common in object-oriented languages.
Next comes a for-loop, where we loop over the indexes of all bytes in the source file.
In the for-loop, we first read a byte from the source
.
Again, note the syntax for this.
Then we write it to the destination.
Then we write the byte we have just read to the destination
file.
Remember that the File::write_byte
expects an &mut self
parameter.
Here, destination
is passed implicitly as the &mut self
parameter, so it
must have type &mut File
. This is why the function copy_data
expects the
parameter destination
to be of type &mut File
.