Fine is a C++ library enabling more ergonomic NIFs, tailored to Elixir.
Erlang provides C API for implementing native functions
(erl_nif
).
Fine is not a replacement of the C API, instead it is designed as a
complementary API, enhancing the developer experience when implementing
NIFs in C++.
-
Automatic encoding/decoding of NIF arguments and return value, inferred from function signatures.
-
Smart pointer enabling safe management of resource objects.
-
Registering NIFs and resource types via simple annotations.
-
Support for encoding/decoding Elixir structs based on compile time metadata.
-
Propagating C++ exceptions as Elixir exceptions, with support for raising custom Elixir exceptions.
-
Creating all static atoms at load time.
Some projects make extensive use of NIFs, where using the C API results in a lot of boilerplate code and a set of ad-hoc helper functions that get copied from project to project. The main idea behind Fine is to reduce the friction of getting from Elixir to C++ and vice versa, so that developers can focus on writing the actual native code.
Currently Fine requires C++17. The supported compilers include GCC, Clang and MSVC.
Add Fine
as a dependency in your mix.exs
:
def deps do
[
{:fine, "~> 0.1.0", runtime: false}
]
end
Modify your makefiles to look for Fine header files, similarly to the ERTS ones. Also make sure to use at least C++17.
# GCC/Clang (Makefile)
CPPFLAGS += -I$(FINE_INCLUDE_DIR)
CPPFLAGS += -std=c++17
# MSVC (Makefile.win)
CPPFLAGS=$(CPPFLAGS) /I"$(FINE_INCLUDE_DIR)"
CPPFLAGS=$(CPPFLAGS) /std:c++17
When using elixir_make
, set FINE_INCLUDE_DIR
like this:
def project do
[
...,
make_env: fn -> %{"FINE_INCLUDE_DIR" => Fine.include_dir()} end
]
end
Otherwise, you can inline the dir to deps/fine/include
.
A minimal NIF adding two numbers can be implemented like so:
#include <fine.hpp>
int64_t add(ErlNifEnv *env, int64_t x, int64_t y) {
return x + y;
}
FINE_NIF(add, 0);
FINE_INIT("Elixir.MyLib.NIF");
Terms are automatically encoded and decoded at the NIF boundary based on the function signature. In some cases, you may also want to invoke encode/decode directly:
// Encode
auto message = std::string("hello world");
auto term = fine::encode(env, message);
// Decode
auto message = fine::decode<std::string>(env, term);
Fine provides implementations for the following types:
Type | Encoder | Decoder |
---|---|---|
fine::Term |
x | x |
int64_t |
x | x |
uint64_t |
x | x |
double |
x | x |
bool |
x | x |
ErlNifPid |
x | x |
ErlNifBinary |
x | x |
std::string |
x | x |
fine::Atom |
x | x |
std::nullopt_t |
x | |
std::optional<T> |
x | x |
std::variant<Args...> |
x | x |
std::tuple<Args...> |
x | x |
std::vector<T> |
x | x |
std::map<K, V> |
x | x |
fine::ResourcePtr<T> |
x | x |
T with struct metadata |
x | x |
fine::Ok<Args...> |
x | |
fine::Error<Args...> |
x |
In some cases, you may want to define a NIF that accepts or returns a term and effectively skip the encoding/decoding. However, the NIF C API defines
ERL_NIF_TERM
as an alias for an integer type, which may introduce an ambiguity for encoding/decoding. For this reason Fine provides a wrapper typefine::Term
and it should be used in the NIF signature in those cases.fine::Term
defines implicit conversion to and fromERL_NIF_TERM
, so it can be used with allenif_*
functions with no changes.
std::string
is just a sequence ofchar
s and therefore it makes for a good counterpart for Elixir binaries, regardless if we are talking about UTF-8 encoded strings or arbitrary binaries.However, when dealing with large binaries, it is preferable for the NIF to accept
ErlNifBinary
as arguments and deal with the raw data explicitly, which is zero-copy. That said, keep in mind thatErlNifBinary
is read-only and only valid during the NIF call lifetime.Similarly, when returning large binaries, prefer creating the term with
enif_make_new_binary
and returningfine::Term
, as shown below.fine::Term read_data(ErlNifEnv *env) { const char *buffer = ...; uint64_t size = ...; ERL_NIF_TERM binary_term; auto binary_data = enif_make_new_binary(env, size, &binary_term); memcpy(binary_data, buffer, size); return binary_term; }You can also return
ErlNifBinary
allocated withenif_alloc_binary
, but keep in mind that returning the binary converts it to term, which in turn transfers the ownership, so you should not use thatErlNifBinary
after the NIF finishes.
You can extend encoding/decoding to work on custom types by defining the following specializations:
// Note that the specialization must be defined in the `fine` namespace.
namespace fine {
template <> struct Decoder<MyType> {
static MyType decode(ErlNifEnv *env, const ERL_NIF_TERM &term) {
// ...
}
};
template <> struct Encoder<MyType> {
static ERL_NIF_TERM encode(ErlNifEnv *env, const MyType &value) {
// ...
}
};
}
Resource objects is a mechanism for passing pointers to C++ data
structures to and from NIFs, and around your Elixir code. On the Elixir
side those pointer surface as reference terms (#Reference<...>
).
Fine provides a construction function fine::make_resource<T>(...)
,
similar to std::make_unique
and std::make_shared
available in the
C++ standard library. This function creates a new object of the type
T
, invoking its constructor with the given arguments and it returns
a smart pointer of type fine::ResourcePtr<T>
. The pointer is
automatically decoded and encoded as a reference term. It can also be
passed around C++ code, automatically managing the reference count
(similarly to std::shared_ptr
).
You need to indicate that a given class can be used as a resource type
via the FINE_RESOURCE
macro.
#include <fine.hpp>
class Generator {
public:
Generator(uint64_t seed) { /* ... */ }
int64_t random_integer() { /* ... */ }
// ...
};
FINE_RESOURCE(Generator);
fine::ResourcePtr<Generator> create_generator(ErlNifEnv *env, uint64_t seed) {
return fine::make_resource<Generator>(seed);
}
FINE_NIF(create_generator, 0);
int64_t random_integer(ErlNifEnv *env, fine::ResourcePtr<Generator> generator) {
return generator->random_integer();
}
FINE_NIF(random_integer, 0);
FINE_INIT("Elixir.MyLib.NIF");
Once neither Elixir nor C++ holds a reference to the resource object,
it gets destroyed. By default only the T
type destructor is called.
However, in some cases you may want to interact with NIF APIs as part
of the destructor. In that case, you can implement a destructor
callback on T
, which receives the relevant ErlNifEnv
:
class Generator {
// ...
void destructor(ErlNifEnv *env) {
// Example: send a message to some process using env
}
};
If defined, the destructor
callback is called first, and then the
T
destructor is called as usual.
Oftentimes NIFs deal with classes from third-party packages, in which
case, you may not control how the objects are created and you cannot
add callbacks such as destructor
to the implementation. If you run
into any of these limitations, you can define your own wrapper class,
holding an object of the third-party class and implementing the desired
construction/destruction on top.
You can use fine::make_resource_binary(env, resource, data, size)
to create a binary term with memory managed by the resource.
Elixir structs can be passed to and from NIFs. To do that, you need to define a corresponding C++ class that includes metadata fields used for automatic encoding and decoding. The metadata consists of:
-
module
- the Elixir struct name as an atom reference -
fields
- a mapping between Elixir struct and C++ class fields -
is_exception
(optional) - when defined as true, indicates the Elixir struct is an exception
For example, given an Elixir struct %MyLib.Point{x: integer, y: integer}
,
you could operate on it in the NIF, like this:
#include <fine.hpp>
namespace atoms {
auto ElixirMyLibPoint = fine::Atom("Elixir.MyLib.Point");
auto x = fine::Atom("x");
auto y = fine::Atom("y");
}
struct ExPoint {
int64_t x;
int64_t y;
static constexpr auto module = &atoms::ElixirMyLibPoint;
static constexpr auto fields() {
return std::make_tuple(std::make_tuple(&ExPoint::x, &atoms::x),
std::make_tuple(&ExPoint::y, &atoms::y));
}
};
ExPoint point_reflection(ErlNifEnv *env, ExPoint point) {
return ExPoint{-point.x, -point.y};
}
FINE_NIF(point_reflection, 0);
FINE_INIT("Elixir.MyLib.NIF");
Structs can be particularly convenient when using NIF resource objects.
When working with resources, it is common to have an Elixir struct
corresponding to the resource. In the previous Generator
example,
you may define an Elixir struct such as %MyLib.Generator{resource: reference}
.
Instead of passing and returning the reference from the NIF, you can
pass and return the struct itself:
#include <fine.hpp>
class Generator {
public:
Generator(uint64_t seed) { /* ... */ }
int64_t random_integer() { /* ... */ }
// ...
};
namespace atoms {
auto ElixirMyLibGenerator = fine::Atom("Elixir.MyLib.Generator");
auto resource = fine::Atom("resource");
}
struct ExGenerator {
fine::ResourcePtr<Generator> resource;
static constexpr auto module = &atoms::ElixirMyLibPoint;
static constexpr auto fields() {
return std::make_tuple(
std::make_tuple(&ExGenerator::resource, &atoms::resource),
);
}
};
ExGenerator create_generator(ErlNifEnv *env, uint64_t seed) {
return ExGenerator{fine::make_resource<Generator>(seed)};
}
FINE_NIF(create_generator, 0);
int64_t random_integer(ErlNifEnv *env, ExGenerator ex_generator) {
return ex_generator.resource->random_integer();
}
FINE_NIF(random_integer, 0);
FINE_INIT("Elixir.MyLib.NIF");
All C++ exceptions thrown within the NIF are caught and raised as Elixir exceptions.
throw std::runtime_error("something went wrong");
// ** (RuntimeError) something went wrong
throw std::invalid_argument("expected x, got y");
// ** (ArgumentError) expected x, got y
throw OtherError(...);
// ** (RuntimeError) unknown exception thrown within NIF
Additionally, you can use fine::raise(env, value)
to raise exception,
where value
is encoded into a term and used as the exception. This
is not particularly useful with regular types, however it can be used
to raise custom Elixir exceptions. Consider the following exception:
defmodule MyLib.MyError do
defexception [:data]
@impl true
def message(error) do
"got error with data #{error.data}"
end
end
First, we need to implement the corresponding C++ class:
namespace atoms {
auto ElixirMyLibMyError = fine::Atom("Elixir.MyLib.MyError");
auto data = fine::Atom("data");
}
struct ExMyError {
int64_t data;
static constexpr auto module = &atoms::ElixirMyLibMyError;
static constexpr auto fields() {
return std::make_tuple(
std::make_tuple(&ExMyError::data, &atoms::data));
}
static constexpr auto is_exception = true;
};
Then, we can raise it anywhere in a NIF:
fine::raise(env, ExMyError{42})
// ** (MyLib.MyError) got error with data 42
It is preferable to define atoms as static variables, this way the corresponding terms are created once, at NIF load time.
namespace atoms {
auto hello_world = fine::Atom("hello_world");
}
When it comes to NIFs, errors often indicate unexpected failures and
raising an exception makes sense, however you may also want to handle
certain errors gracefully by returning :ok
/:error
tuples, similarly
to usual Elixir functions. Fine provides Ok<Args...>
and Error<Args...>
types for this purpose.
fine::Ok<>()
// :ok
fine::Ok<int64_t>(1)
// {:ok, 1}
fine::Error<>()
// :error
fine::Error<std::string>("something went wrong")
// {:error, "something went wrong"}
You can use std::variant
to express a union of possible result types
a NIF may return:
std::variant<fine::Ok<int64_t>, fine::Error<std::string>> find_meaning(ErlNifEnv *env) {
if (...) {
return fine::Error<std::string>("something went wrong");
}
return fine::Ok<int64_t>(42);
}
Note that if you use a particular union frequently, it may be convenient
to define a type alias with using
/typedef
to keep signatures brief.
Some of the ideas have been previously explored by Serge Aleynikov (@saleyn) and Daniel Goertzen (@goertzenator) (source).
Copyright (c) 2025 Dashbit
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at [http://www.apache.org/licenses/LICENSE-2.0](http://www.apache.org/licenses/LICENSE-2.0)
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.