2021-03-10 12:00:00
Calling .NET APIs from Rust
SourceGear.Rust.NET.Sdk (aka the Llama Rust SDK) version 0.1.5 contains some progress on using Rust to call .NET APIs. This blog entry is a closer look at those features.
Reminder: Llama is at the "proof of concept" stage, and is not production ready.
lousygrep
For this walkthrough, I am writing a simplistic implementation of "grep". Here's the C# version:
class Program { static void run(string file, string search) { var a = System.IO.File.ReadAllLines(file); var len = a.Length; for (var i=0; i<len; i++) { var s = a[i]; if (s.Contains(search)) { System.Console.WriteLine(s); } } } static void Main(string[] args) { run(args[0], args[1]); } }
It's a console program that takes exactly two arguments on the command line: the name of a file, and the string to search for. It reads all the lines of the file into memory, and prints the ones that contain the search string.
If you drop this code into Program.cs, and then add the following as lousygrep.csproj, it should build and run.
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net5.0</TargetFramework> </PropertyGroup> </Project>
The goal in this blog entry is to port lousygrep to Rust while keeping it as a .NET program. In other words,
instead of rewriting it to use, for example,
the file I/O stuff in the Rust Standard Library, we want to show how to call .NET Base Class Library things like System.IO
from Rust.
This capability is provided by a binding generator that takes a set of .NET assemblies and generates Rust glue code to expose the APIs to Rust.
Porting to Rust
First we need lousygrep.rsproj, the MSBuild project file. This is the counterpart to the .csproj, and it's pretty similar:
<Project Sdk="SourceGear.Rust.NET.Sdk/0.1.5"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net5.0</TargetFramework> <RustWantRefDotnet>true</RustWantRefDotnet> </PropertyGroup> </Project>
The only two differences here are:
On the
Project
element, theSdk
attribute is "SourceGear.Rust.NET.Sdk/0.1.5". This is what tells MSBuild that we are using a special project SDK.The
WantRefDotnet
element tells the Llama Rust SDK to include a reference to the crates that expose .NET APIs to Rust.
And the code itself goes in src/main.rs:
use dotnet::overloads_with_tuple::*; use dotnet::overloads_special::*; use dotnet::*; fn run(file : &System::String, search : &System::String) -> Result<(),System::Exception> { let a = System::IO::File::ReadAllLines((file,))?; let len = a.Length()?; for i in 0 .. len { let s = a.get_item(i); if s.Contains((search,))? { System::Console::WriteLine((&s,))?; } } Ok(()) } #[no_mangle] pub extern "C" fn main() { let args = System::Environment::GetCommandLineArgs(()).unwrap(); let file = args.get_item(1); let search = args.get_item(2); run(&file, &search).unwrap(); }
Comparing the C# and Rust implementions, you should see several things that are similar in both versions. But even in this very simple example, there are a number of differences worth highlighting.
Importing names
use dotnet::overloads_with_tuple::*; use dotnet::overloads_special::*; use dotnet::*;
These use
statements bring things from the dotnet crate into scope, similar to using
from C#. At the present time, they are a bit more complicated than they need to be. At some point in the future, the ones that contain "overloads" should go away and become implicit.
Namespace separator
let a = System::IO::File::ReadAllLines((file,))?;
In C#, the namespace separator is a dot, so the full name of the method here is System.IO.File.ReadAllLines()
. But when we expose things to Rust as a module hierarchy, we end up with double colons instead.
Note also that this hierarchy would be more Rust-y is the names were all lower case. I've kept the .NET names here.
No overloading
System::Console::WriteLine((&s,))?;
Rust does not support function overloading. More accurately, the only overloading capabilities offered by Rust are through the trait system, which is being used here, but that approach does not offer a way to deal with overloads that have differing number of arguments.
I have implemented two different ways of dealing with overloads. The one shown here is based on tuples. Each .NET method accepts one argument, a tuple, and the contents of that tuple can vary according to the overloads. This means that calling a .NET API involves an extra set of parenthesis.
It also means that a method with exactly one parameter looks even weirder, because the syntax for a single-item tuple in Rust includes an extra comma.
Error propagation
let len = a.Length()?;
Any .NET API can throw an exception, but Rust doesn't have exceptions. The Rust-y way of propagating
errors is to use Result<T,E>
.
So when the binding generator creates a Rust
wrapper, the return type it gives is Result<T, System::Exception>
. Any
exception that gets thrown will be caught and returned as the Err
case of the Result
.
This allows
use of the standard Rust approach of appending a ?
to propagate errors.
The line of code above also illustrates two other things worth mentioning. First, the Length
of a .NET array is a property, so why does this look like a method call? Well, properties in .NET are just a special syntax for a special method call. And Rust does not have this property syntax. So the binding generator exposes them to Rust as methods.
Finally, note that the extra set of parens does not appear here. The argument tuple is not needed at all. A property getter cannot be overloaded, so it is treated as a special case.
Array indexing
let s = a.get_item(i);
Just like in .NET itself, arrays require special handling.
For now, they are exposed to Rust as a type with a get_item()
method. I haven't implemented the Rust Index
trait yet,
but I certainly plan to.
(Hmmm. Actually, get_item()
should return Result
. I need to fix that.)
Signature of main
#[no_mangle] pub extern "C" fn main() {
I've mentioned this bit of annoyance in previous blog entries, and it continues to linger:
Llama needs Rust main
to be FFI (extern "C"
) and to have a non-mangled name.
Command line arguments
let args = System::Environment::GetCommandLineArgs(()).unwrap();
Rust doesn't pass the command line arguments to main like C# does, so we have to call System.Environment.GetCommandLineArgs()
to retrieve them.
Demo
Each version shown searching its own source code for the word "System":
$ dotnet run --no-build Program.cs System var a = System.IO.File.ReadAllLines(file); System.Console.WriteLine(s);
$ dotnet run --no-build src/main.rs System fn run(file : &System::String, search : &System::String) -> Result<(),System::Exception> { let a = System::IO::File::ReadAllLines((file,))?; System::Console::WriteLine((&s,))?; let args = System::Environment::GetCommandLineArgs(()).unwrap();