Chapter 22: FFI and Interop
Time: 90 min | Audience: Advanced | Prerequisites: Chapters 02, 07, 12
Learning Outcomes
After this chapter, you will: - Understand Foreign Function Interface (FFI) concepts - Call C functions safely from Zebra - Marshal data between Zebra and C - Handle errors and exceptions across language boundaries - Know the performance and safety tradeoffs
Overview: Calling External Code
Not all code is Zebra. Sometimes you need to call: - C libraries — system libraries, legacy code, performance-critical code - Zig code — for optimal control or performance - Platform APIs — Windows, Linux, macOS system functions
FFI (Foreign Function Interface) lets you call these from Zebra. This chapter covers the patterns, safety considerations, and common pitfalls.
Calling C Functions
Simple C Function Calls
The simplest case: C functions with primitive types.
// file: ffi-c-simple.zbr
// teaches: calling basic C functions // chapter: 22
// Declare C function signature // Note: This example assumes the function is available at link time shared class Math shared def sqrt(x as float) as float # This would be implemented in C return 0.0
def pow(base as float, exponent as float) as float # C function: double pow(double, double) return 0.0
def main() var result = Math.sqrt(16.0) println(result) # 4.0
var power = Math.pow(2.0, 8.0) println(power) # 256.0
String Marshaling
Strings require special care because Zebra and C have different string representations.
// file: ffi-c-strings.zbr
// teaches: passing strings to C functions // chapter: 22
shared class CString shared # C strlen: int strlen(const char* s) def strlen(s as str) as int # Native C implementation return 0
# C strcmp: int strcmp(const char a, const char b) def strcmp(a as str, b as str) as int # Returns: 0 if equal, <0 if a<b, >0 if a>b return 0
# C strcpy: char strcpy(char dest, const char* src) # WARNING: strcpy is dangerous! Buffer overflow risk! # Better to use strncpy or avoid it entirely
def main() var text = "Hello, World!" var length = CString.strlen(text) println("Length: ${length}")
var cmp = CString.strcmp("apple", "apple") if cmp == 0 println("Strings are equal")
cmp = CString.strcmp("apple", "banana") if cmp < 0 println("apple comes before banana")
Working with Arrays
Arrays are commonly passed to C functions.
// file: ffi-c-arrays.zbr
// teaches: passing arrays to C functions // chapter: 22
shared class CArray shared # C qsort: void qsort(void base, size_t nmemb, size_t size, int (compar)(const void, const void)) # This is complex to use in Zebra—better to sort in Zebra
# Example: sum array (simplified C function) def sum_array(numbers as List(int)) as int # In real C: int sum_array(int* arr, int len) var total = 0 for num in numbers total = total + num return total
# Example: find maximum def max_array(numbers as List(int)) as int var max_val = numbers.at(0) for num in numbers if num > max_val max_val = num return max_val
def main() var numbers = List(int)() numbers.add(10) numbers.add(20) numbers.add(15)
var sum = CArray.sum_array(numbers) println("Sum: ${sum}") # 45
var max_val = CArray.max_array(numbers) println("Max: ${max_val}") # 20
Pointers and Memory Management
This is where FFI gets dangerous.
// file: ffi-c-pointers.zbr
// teaches: handling pointers in FFI // chapter: 22
shared class CMemory shared # C malloc: void* malloc(size_t size) # C free: void free(void* ptr) # These are low-level and error-prone
# Better: allocate in Zebra, pass to C def process_buffer(data as str) as Result(int, str) # Zebra owns the memory, C just reads it # Safe! C cannot deallocate return Result.ok(data.len)
def main() var my_data = "Important data"
# Pass to C function for processing var result = CMemory.process_buffer(my_data)
if result.isOk() println("Processed: ${result.value()} bytes") else println("Error: ${result.error()}")
# Zebra's scoping ensures my_data is cleaned up automatically
Calling Zig Functions
Zig is closer to Zebra, making interop more ergonomic.
Basic Zig Interop
// file: ffi-zig-basic.zbr
// teaches: calling Zig functions from Zebra // chapter: 22
shared class ZigMath shared # Zig function: pub fn gcd(a: i64, b: i64) -> i64 def gcd(a as int, b as int) as int # Implementation in Zig return 0
# Zig function: pub fn is_prime(n: u64) -> bool def is_prime(n as int) as bool return false
def main() var result = ZigMath.gcd(48, 18) println(result) # 6
if ZigMath.is_prime(17) println("17 is prime") else println("17 is not prime")
Zig String Handling
Zig's string handling is different from C's.
// file: ffi-zig-strings.zbr
// teaches: Zig string interop // chapter: 22
shared class ZigString shared # Zig function with slices # pub fn string_length(s: []const u8) -> usize def string_length(s as str) as int return 0
# Case conversion # pub fn to_uppercase(allocator: Allocator, s: []const u8) -> ![]u8 def to_uppercase(s as str) as str return ""
# String validation # pub fn is_valid_utf8(s: []const u8) -> bool def is_valid_utf8(data as str) as bool return true
def main() var text = "Hello, Zig!" var len = ZigString.string_length(text) println("Length: ${len}")
var upper = ZigString.to_uppercase(text) println("Uppercase: ${upper}")
Error Handling Across Boundaries
Return Code Patterns
Many C functions return error codes rather than throwing exceptions.
// file: ffi-error-codes.zbr
// teaches: handling C-style error codes // chapter: 22
shared class CFile shared # C fopen: FILE fopen(const char filename, const char* mode) # Returns NULL on error def open_file(filename as str, mode as str) as Result(int, str) # In real C, this returns FILE* (opaque pointer) # For now, return 0 to indicate error var file_handle = 0 # Attempt to open
if file_handle == 0 return Result.err("Cannot open file: ${filename}") else return Result.ok(file_handle)
# C close: int fclose(FILE* f) # Returns 0 on success, EOF on error def close_file(file_handle as int) as Result(bool, str) var status = 0 # Attempt to close
if status == 0 return Result.ok(true) else return Result.err("Error closing file")
def main() var result = CFile.open_file("data.txt", "r")
branch result on ok(handle) println("File opened: ${handle}")
var close_result = CFile.close_file(handle) if close_result.isOk() println("File closed") on err(error) println("Error: ${error}")
Exception-Like Patterns
Some C libraries use setjmp/longjmp for exceptions. These are complex to use from Zebra—consider wrapping in a C shim.
// file: ffi-error-wrapper.zbr
// teaches: wrapping C error handling in Zebra // chapter: 22
<h1>Example: C library with exception-like behavior</h1> <h1>Rather than exposing this complexity to Zebra code,</h1> <h1>wrap it in a simpler Zebra interface</h1>
shared class SafeLibrary shared # C function might throw (via setjmp/longjmp) def risky_operation(input as str) as Result(str, str) # Wrapper function (in C or Zig) handles exceptions # and returns a Result to Zebra return Result.err("Operation failed")
def main() var result = SafeLibrary.risky_operation("data")
if result.isErr() println("Operation failed safely")
Type Marshaling
Numeric Types
Most numeric types map directly:
// file: ffi-numeric-types.zbr
// teaches: numeric type marshaling // chapter: 22
shared class Numeric shared # Zebra int (64-bit) → C int32_t (32-bit) # Be careful with overflow! def c_int32_function(n as int) as int return 0
# Zebra float → C float or double def c_double_function(x as float) as float return 0.0
# Boolean: Zebra bool → C bool (or int 0/1) def c_bool_function(flag as bool) as bool return false
def main() # Small numbers are safe var result = Numeric.c_int32_function(100) println(result)
# Large numbers may overflow in C int32 # Be careful! var large_num = 2147483647 + 1 # Exceeds int32 max # Don't pass to C int32 functions!
Collections and Structures
Collections require more care:
// file: ffi-structures.zbr
// teaches: passing structures across FFI boundary // chapter: 22
class Point var x as float var y as float
def init(x as float, y as float) this.x = x this.y = y
shared class Geometry shared # C function: float distance(struct Point a, struct Point b) # Assuming C expects Point with fields x, y def distance(p1 as Point, p2 as Point) as float # Implementation var dx = p2.x - p1.x var dy = p2.y - p1.y return 0.0 # sqrt(dxdx + dydy)
def main() var p1 = Point(0.0, 0.0) var p2 = Point(3.0, 4.0)
var dist = Geometry.distance(p1, p2) println(dist) # ~5.0 (3-4-5 triangle)
Platform-Specific Code
Windows vs. Unix
Different platforms have different APIs.
// file: ffi-platform-specific.zbr
// teaches: handling platform differences // chapter: 22
shared class Platform shared # Windows: GetFileSize # Unix: stat def get_file_size(filename as str) as Result(int, str) # Implementation varies by platform return Result.ok(0)
def get_environment_variable(name as str) as str? # Implemented via getenv (Unix) or GetEnvironmentVariable (Windows) return nil
def sleep_milliseconds(ms as int) # Windows: Sleep() # Unix: usleep() pass
def main() var size_result = Platform.get_file_size("data.txt")
if size_result.isOk() println("File size: ${size_result.value()} bytes")
Conditional Compilation
// file: ffi-conditional.zbr
// teaches: platform-specific compilation // chapter: 22
shared class OSSpecific shared def platform_name() as str # This might vary based on compilation target return "Unknown"
def file_separator() as str # Windows: \, Unix: / return "/"
def main() println("Platform: ${OSSpecific.platform_name()}") println("Separator: ${OSSpecific.file_separator()}")
Safety Considerations
Memory Safety
The biggest FFI risk: memory management.
// file: ffi-safety-memory.zbr
// teaches: FFI memory safety // chapter: 22
<h1>SAFE: Zebra owns memory</h1> def safe_pattern(data as str) as int # Zebra created the string # Pass it to C for reading only # C should NOT modify or deallocate return data.len
<h1>UNSAFE: C allocates memory Zebra must free</h1> <h1>shared class Unsafe</h1> <h1> shared</h1> <h1> def allocate_buffer() as str</h1> <h1> # C allocates memory with malloc</h1> <h1> # Zebra must call free</h1> <h1> # This is error-prone! Don't do this.</h1> <h1> return ""</h1>
<h1>BETTER: Provide deallocation function</h1> shared class BetterAlloc shared # C allocates def create_buffer(size as int) as int return 0 # Returns opaque handle
# Zebra must call this to free def destroy_buffer(handle as int) pass
def main() var buf = BetterAlloc.create_buffer(1024) # Use buffer... BetterAlloc.destroy_buffer(buf) # buf is now invalid! Don't use it again.
Type Safety
Type mismatches can cause crashes.
// file: ffi-safety-types.zbr
// teaches: type safety across FFI boundaries // chapter: 22
shared class TypeSafety shared # C expects: void process_array(int* arr, int len) def process_array(arr as List(int)) # Must match! List(str) would be wrong. pass
# C expects: int sum(float* values, int count) def sum(values as List(float)) as int # Values must be floats, not ints return 0
def main() # Correct usage var ints = List(int)() ints.add(1) ints.add(2) ints.add(3) # process_array(ints) # Would need implementation
var floats = List(float)() floats.add(1.5) floats.add(2.5) # var total = sum(floats) # Correct
# WRONG: Would cause problems # var total = sum(ints) # Type mismatch!
Lifetime Issues
Pointers can outlive their targets.
// file: ffi-safety-lifetime.zbr
// teaches: avoiding pointer lifetime issues // chapter: 22
<h1>UNSAFE: Reference to local variable</h1> <h1>def dangerous() as int</h1> <h1> var local = 42</h1> <h1> var ptr = address_of(local) # Get pointer</h1> <h1> # local goes out of scope here</h1> <h1> # ptr now points to garbage!</h1> <h1> return 0</h1>
<h1>SAFE: Return value, not reference</h1> def safe_return(n as int) as int var result = n * 2 # result is copied into return value # No dangling pointers return result
<h1>SAFE: Use parameters</h1> def safe_parameter(numbers as List(int)) as int # List is passed by reference, lives in caller's scope # Safe to use while caller owns it return numbers.at(0)
def main() var my_list = List(int)() my_list.add(42)
# Safe—my_list is still alive var first = safe_parameter(my_list) # After main returns, my_list is cleaned up
Practical Example: Crypto Library Integration
// file: ffi-crypto-example.zbr
// teaches: practical FFI example with crypto // chapter: 22
shared class Crypto shared # OpenSSL/BoringSSL: compute SHA256 def sha256(input as str) as str # C function: # void SHA256(const unsigned char d, size_t n, unsigned char md) return ""
# Verify hash matches expected value def verify_sha256(input as str, expected_hash as str) as bool var computed = sha256(input) return computed == expected_hash
def main() var message = "Secret password" var hash = Crypto.sha256(message) println("SHA256: ${hash}")
# Verify integrity var stored_hash = "a665a45920422f9d417e4867efdc4fb8a04a1d3a4ff2d42bfa0f1db5e2ce9ba"
if Crypto.verify_sha256(message, stored_hash) println("Hash verified!") else println("Hash mismatch!")
Performance Considerations
Call Overhead
FFI calls have overhead:
// file: ffi-performance.zbr
// teaches: FFI performance tradeoffs // chapter: 22
def main() # FFI calls are expensive compared to Zebra calls # If you're calling an FFI function in a tight loop, # consider moving the loop into C
# BAD: Loop in Zebra, FFI call per iteration var sum = 0 for i in 0.to(1000000) sum = sum + expensive_c_function(i)
# BETTER: Pass the whole array to C var nums = List(int)() for i in 0.to(1000000) nums.add(i)
sum = sum_all(nums) # Single FFI call
def expensive_c_function(n as int) as int return n * 2
def sum_all(nums as List(int)) as int var total = 0 for num in nums total = total + num return total
Batching Operations
// file: ffi-batching.zbr
// teaches: batching FFI operations // chapter: 22
shared class Batch shared # Process one item (slow) def process_item(item as str) as str return item.upper()
# Process many items (fast) def process_batch(items as List(str)) as List(str) # Single FFI call for all items return items
def main() var items = List(str)() for i in 0.to(100) items.add("item-${i}")
# GOOD: Batch processing var results = Batch.process_batch(items)
# BAD: Individual calls # for item in items # var result = Batch.process_item(item) # 100 FFI calls!
Key Takeaways
1. Safety First — Memory management is dangerous. Prefer Zebra ownership.
2. Use Result Types — C errors become Zebra Result types automatically.
3. Type Carefully — Type mismatches can cause crashes.
4. Batch Calls — Multiple small FFI calls are slower than one big call.
5. Document Ownership — Who owns allocated memory? Make it clear.
6. Test Thoroughly — FFI bugs are subtle and platform-specific.
When NOT to Use FFI
- Pure Zebra solution exists — Use it instead - Performance critical loop — Consider rewriting the loop in C/Zig - Simple algorithm — Zebra is fast enough for most things - Unclear memory ownership — Wrap in C to clarify
Exercises
1. Hash Function Wrapper — Wrap OpenSSL's SHA256 safely 2. Random Number Generator — Call system random via FFI 3. JSON Parser — Integrate a C JSON library with error handling 4. Text Processing — Call ICU for Unicode operations 5. System Information — Retrieve CPU count, memory, etc. via platform APIs
What's Next
You've reached the end of the language itself. What follows are the appendices—grammar reference, standard library summary, and troubleshooting.