Tutorial
A Zngur project consists of 3 things:
- An IDL (interface definition language) file named
main.zng
- A Rust crate (that can be everything, binary, rlib, static-lib, cdy-lib, ...)
- A C++ project.
To start, install Zngur:
cargo install zngur-cli
Then generate a new staticlib
crate using cargo init
and appending this to the Cargo.toml
:
[lib]
crate-type = ["staticlib"]
And create an empty main.cpp
and main.zng
file. Your directory tree should look like this:
├── Cargo.toml
├── main.cpp
├── main.zng
└── src
└── lib.rs
Basic structure of main.zng
Imagine we want to use this inventory in C++:
struct Item {
name: String,
size: u32,
}
struct Inventory {
items: Vec<Item>,
remaining_space: u32,
}
impl Inventory {
fn new_empty(space: u32) -> Self {
Self {
items: vec![],
remaining_space: space,
}
}
}
Copy it into src/lib.rs
. Now we need to declare things that we need to access in C++ in the main.zng
file:
type crate::Inventory {
#layout(size = 32, align = 8);
fn new_empty(u32) -> crate::Inventory;
}
Zngur needs to know the size and align of the types inside the bridge. You can figure it out using the rust-analyzer (by hovering over the struct or type alias) or fill it with some random number and then fix it from the compiler error.
Note: Ideally
main.zng
file should be auto-generated, but we are not there yet. Also, Zngur can work without explicit size and align (with some caveats), see layout policies for more details.
Now, run zngur g ./main.zng
to generate the C++ and Rust glue files. It will generate a ./generated.h
C++ header file, and a
./src/generated.rs
file. Add a mod generated;
to your lib.rs
file to include the generated Rust file. Then fill main.cpp
file
with the following content:
#include "./generated.h"
int main() {
auto inventory = rust::crate::Inventory::new_empty(1000);
}
Zngur will add every Rust item with its full path in the rust
namespace, so for example String
will become rust::std::string::String
in the
C++ side.
To build it, you need to first build the Rust code using cargo build
, which will generate a libyourcrate.a
in the ./target/debug
folder, and
you can build your C++ code by linking to it:
clang++ main.cpp -g -L ./target/debug/ -l your_crate
To ensure that everything works, let's add a #[derive(Debug)]
to Inventory
and use zngur_dbg
to see it:
type crate::Inventory {
#layout(size = 32, align = 8);
wellknown_traits(Debug);
fn new_empty(u32) -> crate::Inventory;
}
int main() {
auto inventory = rust::crate::Inventory::new_empty(1000);
zngur_dbg(inventory);
}
There are some traits that Zngur has special support for them, and Debug
is among them. This page has the
complete list of them.
Assuming that everything works correctly, you should see something like this after executing the program:
[main.cpp:5] inventory = Inventory {
items: [],
remaining_space: 1000,
}
Now let's add some more methods to it:
impl Inventory {
fn add_item(&mut self, item: Item) {
self.remaining_space -= item.size;
self.items.push(item);
}
fn add_banana(&mut self, count: u32) {
for _ in 0..count {
self.add_item(Item {
name: "banana".to_owned(),
size: 7,
});
}
}
}
type () {
#layout(size = 0, align = 1);
}
type crate::Inventory {
#layout(size = 32, align = 8);
wellknown_traits(Debug);
fn new_empty(u32) -> crate::Inventory;
fn add_banana(&mut self, u32);
}
Note that the return type of add_banana
is the ()
type, so we need to add it as well. Now we can use it in the C++ file:
#include "./generated.h"
int main() {
auto inventory = rust::crate::Inventory::new_empty(1000);
inventory.add_banana(3);
zngur_dbg(inventory);
}
[main.cpp:6] inventory = Inventory {
items: [
Item {
name: "banana",
size: 7,
},
Item {
name: "banana",
size: 7,
},
Item {
name: "banana",
size: 7,
},
],
remaining_space: 979,
}
Bridging the add_item
method requires a little more effort. We need to declare crate::Item
type as well since it is an argument
of that function:
// ...
type crate::Item {
#layout(size = 32, align = 8);
}
type crate::Inventory {
// ...
fn add_item(&mut self, crate::Item);
}
But using that alone we can't use the add_item
since there is no way to obtain a rust::crate::Item
in the C++ side. To fix that, we
need to add the constructor for the Item
:
type ::std::string::String {
#layout(size = 24, align = 8);
}
type crate::Item {
#layout(size = 32, align = 8);
constructor { name: ::std::string::String, size: u32 };
}
But it doesn't solve the problem, since we can't create a String
so we can't call the constructor. To creating a String
, we declare the
primitive type str
and its to_owned
method:
type str {
wellknown_traits(?Sized);
fn to_owned(&self) -> ::std::string::String;
}
There are some new things here. First, since str
is a primitive it doesn't need full path. Then there is wellknown_traits(?Sized)
instead
of #layout(size = X, align = Y)
which tells Zngur that this type is unsized and it should consider its references as fat and prevent storing it
by value.
Now you may wonder how we can obtain a &str
to make a String
from it? Fortunately, Zngur has some special support for primitive types and it
has a rust::Str::from_char_star
function that creates a &str
from a zero terminated, valid UTF8 char*
with the same lifetime. If Zngur didn't
have this, we could create a &[u8]
by exporting its from_raw_parts
and then converting it to a &str
, from_char_star
exists just for convenience.
So now we can finally use the add_item
method:
int main() {
auto inventory = rust::crate::Inventory::new_empty(1000);
inventory.add_banana(3);
rust::Ref<rust::Str> name = rust::Str::from_char_star("apple");
inventory.add_item(rust::crate::Item(name.to_owned(), 5));
zngur_dbg(inventory);
}
[main.cpp:8] inventory = Inventory {
items: [
Item {
name: "banana",
size: 7,
},
Item {
name: "banana",
size: 7,
},
Item {
name: "banana",
size: 7,
},
Item {
name: "apple",
size: 5,
},
],
remaining_space: 974,
}
Generic types
Let's try to add and bridge the into_items
method:
impl Inventory {
fn into_items(self) -> Vec<Item> {
self.items
}
}
Vec
is a generic type, but the syntax to use it is not different:
type ::std::vec::Vec<crate::Item> {
#layout(size = 24, align = 8);
wellknown_traits(Debug);
}
type crate::Inventory {
// ...
fn into_items(self) -> ::std::vec::Vec<crate::Item>;
}
Note that this only brings Vec<Item>
, for using Vec<i32>
or Vec<String>
or Vec<SomethingElse>
you need to add each of
them separately.
Now you can use into_items
method in C++:
rust::std::vec::Vec<rust::crate::Item> v = inventory.into_items();
zngur_dbg(v);
[main.cpp:11] v = [
Item {
name: "banana",
size: 7,
},
Item {
name: "banana",
size: 7,
},
Item {
name: "banana",
size: 7,
},
Item {
name: "apple",
size: 5,
},
]
You can see the full code at examples/tutorial
Calling C++ from Rust
C++/Rust interop has two sides, and no interop tool is complete without supporting both. Here, we will do the reverse of the above task, swapping the Rust and C++ rules. So, let's assume we have this C++ code:
#include <string>
#include <vector>
namespace cpp_inventory {
struct Item {
std::string name;
uint32_t size;
};
struct Inventory {
std::vector<Item> items;
uint32_t remaining_space;
Inventory(uint32_t space) : items(), remaining_space(space) {}
void add_item(Item item) {
remaining_space -= item.size;
items.push_back(std::move(item));
}
void add_banana(uint32_t count) {
add_item(Item{
.name = "banana",
.size = 7,
});
}
};
} // namespace cpp_inventory
Create a new cargo project, this time a binary one since we want to write the main function to live inside Rust. Copy the above code into
the inventory.h
file. Then create a main.zng
file with the following content:
type crate::Inventory {
#layout(size = 16, align = 8);
constructor(ZngurCppOpaqueOwnedObject);
#cpp_value "0" "::cpp_inventory::Inventory";
}
type crate::Item {
#layout(size = 16, align = 8);
constructor(ZngurCppOpaqueOwnedObject);
#cpp_value "0" "::cpp_inventory::Item";
}
And add these to the main.rs
file:
mod generated {
include!(concat!(env!("OUT_DIR"), "/generated.rs"));
}
struct Inventory(generated::ZngurCppOpaqueOwnedObject);
struct Item(generated::ZngurCppOpaqueOwnedObject);
This time we will use the Zngur generator inside of cargo build script. We could still use the zngur-cli
but in projects
where cargo is the boss, using build script is better. Add zngur
and cc
to your build dependencies:
[build-dependencies]
cc = "1.0"
build-rs = "0.1.2" # This one is optional
zngur = "latest-version"
Then fill the build.rs
file:
use std::{env, path::PathBuf};
use zngur::Zngur;
fn main() {
build::rerun_if_changed("main.zng");
build::rerun_if_changed("impls.cpp");
let crate_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
Zngur::from_zng_file(crate_dir.join("main.zng"))
.with_cpp_file(out_dir.join("generated.cpp"))
.with_h_file(out_dir.join("generated.h"))
.with_rs_file(out_dir.join("generated.rs"))
.generate();
let my_build = &mut cc::Build::new();
let my_build = my_build
.cpp(true)
.compiler("g++")
.include(&crate_dir)
.include(&out_dir);
let my_build = || my_build.clone();
my_build()
.file(out_dir.join("generated.cpp"))
.compile("zngur_generated");
my_build().file("impls.cpp").compile("impls");
}
Now we have a crate::Inventory
and a crate::Item
that can contain their C++ counterparts. But there is no way to use
them in Rust. In Zngur, the Rust side can't access C++ opaque objects. So to make these types useful in Rust, we can
add impl
blocks for these types in C++. Add this to the main.zng
:
type str {
wellknown_traits(?Sized);
fn as_ptr(&self) -> *const u8;
fn len(&self) -> usize;
}
extern "C++" {
impl crate::Inventory {
fn new_empty(u32) -> crate::Inventory;
fn add_banana(&mut self, u32);
fn add_item(&mut self, crate::Item);
}
impl crate::Item {
fn new(&str, u32) -> crate::Item;
}
}
Now we can define these methods in the C++ and use them in Rust. Create a file named impls.cpp
with this content:
#include "generated.h"
#include <string>
using namespace rust::crate;
Inventory rust::Impl<Inventory>::new_empty(uint32_t space) {
return Inventory(
rust::ZngurCppOpaqueOwnedObject::build<cpp_inventory::Inventory>(space));
}
rust::Unit rust::Impl<Inventory>::add_banana(rust::RefMut<Inventory> self,
uint32_t count) {
self.cpp().add_banana(count);
return {};
}
rust::Unit rust::Impl<Inventory>::add_item(rust::RefMut<Inventory> self,
Item item) {
self.cpp().add_item(item.cpp());
return {};
}
Item rust::Impl<Item>::new_(rust::Ref<rust::Str> name, uint32_t size) {
return Item(rust::ZngurCppOpaqueOwnedObject::build<cpp_inventory::Item>(
cpp_inventory::Item{
.name = ::std::string(reinterpret_cast<const char *>(name.as_ptr()),
name.len()),
.size = size}));
}
These functions look like some unnecessary boilerplate, but writing them has some benefits:
- We can convert C++ types to the Rust equivalents in these functions. For example, converting a pointer and length to a slice, or
&str
tostd::string
that happened in theItem::new
above. - We can convert exceptions to Rust
Result
orOption
. - We can control the signature of methods, and use proper lifetimes and mutability for references. In case of mutability, Rust mutability means
exclusiveness, which might be too restrictive and we may want to consider the C++ type interior mutable. We can also add nullability with
Option
or make the functionunsafe
. - We can choose Rusty names for the functions (like
new
andlen
) or put the functionality in the proper trait (for example implementing theIterator
trait instead of exposing the.begin
and.end
functions)
Even in the tools that support calling C++ functions directly, people often end up writing Rust wrappers around C++ types for these reasons. In Zngur, that code is the wrapper, which lives in the C++ so it can do whatever C++ does.
In the Rust to C++ side, we used zngur_dbg
macro to see the result. We will do the same here with the dbg!
macro. To do that, we need to implement
the Debug
trait for crate::Inventory
. Add this to the main.zng
:
// ...
type ::std::fmt::Result {
#layout(size = 1, align = 1);
constructor Ok(());
}
type ::std::fmt::Formatter {
#layout(size = 64, align = 8);
fn write_str(&mut self, &str) -> ::std::fmt::Result;
}
extern "C++" {
// ...
impl std::fmt::Debug for crate::Inventory {
fn fmt(&self, &mut ::std::fmt::Formatter) -> ::std::fmt::Result;
}
}
and this code to the impls.cpp
:
rust::std::fmt::Result rust::Impl<Inventory, rust::std::fmt::Debug>::fmt(
rust::Ref<::rust::crate::Inventory> self,
rust::RefMut<::rust::std::fmt::Formatter> f) {
::std::string result = "Inventory { remaining_space: ";
result += ::std::to_string(self.cpp().remaining_space);
result += ", items: [";
bool is_first = true;
for (const auto &item : self.cpp().items) {
if (!is_first) {
result += ", ";
} else {
is_first = false;
}
result += "Item { name: \"";
result += item.name;
result += "\", size: ";
result += ::std::to_string(item.size);
result += " }";
}
result += "] }";
return f.write_str(rust::Str::from_char_star(result.c_str()));
}
So now we can write the main function:
fn main() {
let mut inventory = Inventory::new_empty(1000);
inventory.add_banana(3);
inventory.add_item(Item::new("apple", 5));
dbg!(inventory);
}
and run it:
[examples/tutorial_cpp/src/main.rs:12] inventory = Inventory { remaining_space: 974, items: [Item { name: "banana", size: 7 }, Item { name: "banana", size: 7 }, Item { name: "banana", size: 7 }, Item { name: "apple", size: 5 }] }
You can see the full code at examples/tutorial_cpp