While reading this blogpost that talks about the journey of adding Z3 bindings to Crystal I was going to suggest the author to start using to_unsafe
in their code so they don’t have to manually convert things from Crystal to C.
But then I realized that wouldn’t always work… I’ll try to explain why.
In Z3 there’s an opaque type LibZ3::Ast = Void*
type. Then we can define a wrapper for it:
module Z3
struct IntExpr
def initialize(expr : LibZ3::Ast)
@expr = expr
end
end
end
By defining to_unsafe
in that struct we can directly pass such instances to C. So far, so good!
However, in some places the C API accepts a pointer of LibZ3::Ast
together with a length: the usual way one passes lists of things to C.
If in Crystal you are working with the high-level interface, you are working with Z3::IntExpr
instances. If you have a bunch of these, you will have Array(Z3::IntExpr)
. Then if you want to pass that array to C, you have to do something like:
array.map(&.to_unsafe)
That is, go from Array(Z3::IntExpr)
to Array(LibZ3::Ast)
. Because there’s Array#to_unsafe
, that can be automatically passed to C as the to_unsafe
method of this array will be Pointer(LibZ3::Ast)
.
So with all of this I had some observations:
- That
map(&.to_unsafe)
call will allocate a new array, but the byte contents of the array contents will be exactly the same. After all a wrapper struct holds the same bytes as the wrapped struct. So first it’s inefficient to do so. In fact in the compiler’s code we do something different, something likearray.to_unsafe.as(LibZ3::Ast*)
. That is, we assume theto_unsafe
call has a pointer that points to the same memory layout we need. - It seems wrapper structs are very common. We can have a
to_unsafe
, but why not make it simpler?
So here’s the final idea:
- If you define a Crystal struct, and that struct contains exactly one instance variable, and that variable’s type matches the C type, just let it pass, without any extra work. Let’s call this a “wrapper struct”.
- If you have a pointer of a wrapper struct, and the C function expects a pointer of the wrapped struct, just let it pass: the memory layout behind the pointer will match
- If you have a type that doesn’t match the C function argument, try to call
to_unsafe
on it. And here apply the same rules again: if that type matches, it’s all good. And it’s also good if theto_unsafe
call returns a pointer of a wrapper struct, and the C function expects a pointer of a wrapped struct
I created a draft PR for this. It was very easy to implement! And I tried this in the compiler’s code, and also in the Z3 bindings that are being created. The code ends up being so much simpler.
Is there a catch? Like everything, yes. I only see one downside of this: it’s implicit behavior. So if you have a struct that wraps a C value, but then you also have some other instance vars, it won’t work out of the box. You will have to have a to_unsafe
method. And of course arrays of these types won’t work. Adding other instance vars can also be done by reopening a type, so your code might stop compiling as soon as you do that.
BUT. I think in most cases:
- wrapper structs just wrap one value. It’s uncommon to also include more data
- reopening wrapper structs is very, very uncommon. I’d say it simply doesn’t happen. Nobody wants to mess with a type that wraps a C type. Nobody wants to mess with C :-D
- you can always define a
to_unsafe
if the implicit wrapper struct doesn’t work for you
What do you think?
I sent a few different proposals in the past, but I feel this proposal actually adds value to the language, by simplifying interfacing with C, and also by ending up with more efficient code (no need to map(&.to_unsafe)
)