Zig is an an emergent general-purpose programming language. Being "a better C", Zig is an order of magnitude simpler than Rust. You can understand Zig code almost immediately if you can read C. It makes Zig a good candidate to implement FFI extensions for Ruby.
Let's write a tiny little Zig library and call it via FFI from Ruby.
A Zig function with parameters passed "by value" is too simple for our example. To complicate the matter a bit, we will implement a function with "out" parameters to return a collection of structs from Zig to Ruby. In order to simplify domain, let our function to return a collection of random X:Y points. Our build.zig
might look like:
const std = @import("std");
pub fn build(b: *std.build.Builder) void {
const lib = b.addSharedLibrary("points", "src/main.zig", .unversioned);
lib.linkLibC();
lib.setBuildMode(.ReleaseSafe);
lib.install();
}
Since library versioning doesn't directly relate to the topic, we leave the lib unversioned. Please also note that we link LibC. It allows us to use std.heap.c_allocator
to allocate memory for points.
A function to generate a collection of points has signature export fn generatePoints(addr_ptr: *usize, len_ptr: *u8) bool
. There is no mechanism to simply return a collection as a function return value. Thus we need two "out" function parameters:
- an address of the collection (
addr_ptr
) - collection length (
len_ptr
)
class Point < FFI::Struct
layout :x, :uchar,
:y, :uchar
end
module PointsLib
extend FFI::Library
ffi_lib "zig-out/lib/libpoints.so"
attach_function :generatePoints, [ULongPtr, UCharPtr], :bool
ULongPtr
and UCharPtr
is a couple of auxiliary classes we introduce nearby.
class ULongPtr < FFI::Struct
layout :value, :ulong
end
class UCharPtr < FFI::Struct
layout :value, :char
end
They allow us to handle the "out" parameters more elegantly. Just note that, attaching and using the function, we don't directly mention Point
.
addr_ptr = ULongPtr.new
len_ptr = UCharPtr.new
PointsLib.generatePoints(addr_ptr, len_ptr) or raise("Error in Zig")
addr, len = addr_ptr[:value], len_ptr[:value]
It is time to write Zig code.
const Point = extern struct {
x: i8,
y: i8,
};
export fn generatePoints(addr_ptr: *usize, len_ptr: *u8) bool {
var rnd = std.rand.DefaultPrng.init(std.crypto.random.int(u64)).random();
var len = rnd.int(u8); // 1
var points = std.heap.c_allocator.alloc(Point, len) catch return false; // 2
var i: u8 = 0;
while (i < len) : (i += 1) {
points[i].x = rnd.int(i8); // 3
points[i].y = rnd.int(i8);
}
addr_ptr.* = @ptrToInt(points.ptr); // 4
len_ptr.* = len;
return true;
}
As you can see, we
- randomly generate length of the collection
- allocate memory for a slice of Points
- iterate over the slice and randomly assign coordinates
- set a memory address of the collection and collection length as "out" params values
Returning to Ruby. We use FFI::Pointer to access the passed collection.
addr, len = addr_ptr[:value], len_ptr[:value]
points_ptr = FFI::Pointer.new(Point.size, addr)
Having a pointer to the collection, we can get points:
first_point = Point.new(points_ptr[0])
second_point = Point.new(points_ptr[1])
x, y = first_point[:x], first_point[:y]
The only missing piece is freeing the memory passed to Ruby from Zig. It is a separate Zig function. On Ruby side it looks like:
attach_function :freePoints, [:ulong, :uchar], :void
and
PointsLib.freePoints addr, len
On Zig side it is just:
export fn freePoints(points_ptr: [*]Point, len: u8) void {
std.heap.c_allocator.free(points_ptr[0..len]);
}
A completed Ruby method which returns an array of [x, y]
arrays is written as:
def self.coordinates
addr_ptr = ULongPtr.new
len_ptr = UCharPtr.new
PointsLib.generatePoints(addr_ptr, len_ptr) or raise("Error in Zig")
addr, len = addr_ptr[:value], len_ptr[:value]
points_ptr = FFI::Pointer.new(Point.size, addr)
coords = (0...len).map do |i|
point = Point.new(points_ptr[i])
[point[:x], point[:y]]
end
PointsLib.freePoints addr, len
coords
end
That is pretty much it. There are probably many other ways to implement the same functionality. You can play with FFI::ManagedStruct
to get rid of an explicit PointsLib.freePoints
call. Or you can allocate memory on Ruby side and pass it to Zig to fill up with random points. In this post, we will limit ourselves to only one approach.
As a recap. We generated a random number of structures on Zig side, returned and used them on Ruby side and freed allocated memory in the end.
Source code is available on Github.
Add a comment