In previous chapters, we hacked a C++ vtable and learned to use ThisCall to pass the object context smoothly. Now, we're going to combine those techniques with wrap_owned to create the Universal Object Pattern.
This pattern completely hides the C++ FFI boundary from your end-users. They interact with a standard Perl object, and behind the scenes, Affix manages the memory lifecycle and translates the method calls.
The Recipe
Let's assume we have an opaque C++ Widget class. We will wrap it in a pure Perl class.
use v5.40;
use Affix qw[:all];
use Affix::Build;
# Compile a tiny C++ library with a Widget class (C shims only)
my $c = Affix::Build->new();
$c->add( \<<~'CPP', lang => 'cpp' );
#include <iostream>
class Widget {
public:
virtual ~Widget() {
std::cout << "Widget destroyed" << std::endl;
}
virtual void do_work(int iterations) {
for (int i = 0; i < iterations; ++i)
std::cout << "Widget::do_work iteration " << i << std::endl;
}
};
extern "C" {
Widget* widget_new() { return new Widget(); }
void widget_delete(Widget* w) { delete w; }
}
CPP
# find_symbol() needs an Affix::Lib object, not a bare path
my $lib_obj = Affix::load_library( $c->link );
package Native::Widget {
use v5.40;
use Affix qw[:all];
# Closure over the library handle from the enclosing scope
my $LIB = $lib_obj;
# 1. Bind the C shims that spawn the object
affix $LIB, 'widget_new', [] => Pointer[Void];
my $dtor = Affix::find_symbol( $LIB, 'widget_delete' );
# 2. Construct the Class
sub new($class) {
# Allocate the C++ object
my $raw_ptr = widget_new();
# Tie the C++ destructor to the Perl object's lifecycle
my $managed_memory = main::wrap_owned( Affix::address($raw_ptr), Affix::address($dtor) );
# Store the managed memory inside our Perl object
return bless { _c_obj => $managed_memory }, $class;
}
# 3. Dynamic Method Binding (Lazy VTable Lookup)
sub do_work($self, $iterations) {
state $method;
# Extract the vtable function pointer on the first call
if (!$method) {
my $vptr_ref = cast( $self->{_c_obj}, Pointer[Size_t] );
my $vtable_addr = $$vptr_ref;
my $vtable = cast( $vtable_addr, Array[ Size_t, 3 ] );
# Let's assume do_work is virtual method #2 (Itanium ABI)
my $func_addr = $vtable->[2];
$method = wrap( undef, $func_addr, '(*void,int)->void' );
}
# Execute the C++ method, passing the native object pointer first
$method->( $self->{_c_obj}, $iterations );
}
}
# 4. Use it!
my $w = Native::Widget->new();
$w->do_work(3);
undef $w; # Perl object destroyed -> C++ destructor fires
Which prints:
Widget::do_work iteration 0
Widget::do_work iteration 1
Widget::do_work iteration 2
Widget destroyed
How It Works
- The Facade
To a user of
Native::Widget, there is no FFI. They simply callmy $w = Native::Widget->new()and$w->do_work(5). The class is a plain blessed hash; all the vtable surgery happens inside the class, hidden from the consumer. - The Memory Anchor (
wrap_owned)wrap_ownedtakes the raw integer address of the C++ object plus the raw integer address of a destructor shim and returns a blessedAffix::Memoryobject. When that object is garbage collected, itsDESTROYcalls the destructor shim (widget_delete), which in turndeletes the C++ object. By storing theAffix::Memoryinside the$selfhash, we anchor the C++ object's lifecycle to the Perl object: when$wfalls out of scope,$self->{_c_obj}is destroyed and the C++ destructor fires automatically. - Dynamic Method Binding
The first
do_workcall reads the object's vtable. The first word of the object points to it and binds slot 2 (the virtualdo_work) into a JIT trampoline viawrap. The signature leads with*voidbecause the hiddenthispointer arrives as the first argument. - Stateful Optimization
By using the
state $methodvariable insidedo_work, we only perform the heavy VTable lookup and JIT compilation on the first method call. Subsequent calls jump straight into the cached trampoline, providing immense performance.
Kitchen Reminders
wrap_ownedlives inmain::wrap_owned(andalloc_owned) are installed into themain::namespace by Affix's bootstrap, so inside your ownpackageyou must call them fully qualified:main::wrap_owned(...). This is the same helper the test suite uses to manage C++ destructor lifetimes.address()unwraps pins to integerswrap_ownedwants plain integer addresses.Affix::address($pin)returns the rawUVof a pinned pointer, and for a symbol pin fromfind_symbolit returns the symbol's value. Note thatfind_symbolrequires anAffix::Libobject. CallAffix::load_library($path)first; a bare path string will not work.- VTable cast pattern
Use
cast($obj, Pointer[Size_t]), dereference with$$to get the vtable address, thencast($vtable_addr, Array[ Size_t, 3 ])to index the slots. Do not cast toPointer[Pointer[...]]orPointer[Array[...]]; those return a scalar pin, and indexing it raisesNot an ARRAY reference(see Chapter 57). wrapand the signature stringwrap's prototype ($$$;$) blocks the two-argument form, so always pass three arguments:wrap( undef, $func_addr, '(*void,int)->void' ). The signature string uses C syntax (*void,int). TheStringtype object is only valid in[args] => retpairs.- Passing
Affix::Memoryas an argument The marshaller recognizes anAffix::Memoryobject as a pointer handle, so you can hand$self->{_c_obj}straight to the JIT trampoline as thethispointer. - ABI Fragility As I mentioned in previous chapters, vtable layouts are ABI-dependent. The recipe assumes the Itanium ABI (GCC/Clang/MinGW) where slot 2 is the first user-defined virtual method. MSVC on Windows may place it elsewhere. Always double-check your target platform's layout.
Comments