[php-src] Issue #23229: [FFI] Opt-in mapping of C struct types to userland PHP classes (typed CData handles)
[email protected] (lisachenko)
| Newsgroups | php.bugs |
|---|---|
| Message-ID | <[email protected]> |
Issue: https://github.com/php/php-src/issues/23229
Author: lisachenko
### Description
### Feature request
PHP FFI represents every C value — a `struct zend_string*`, a `zval*`, a
`char*`, an `int` — as one and the same final class, `FFI\CData`. That single
opaque type is what makes FFI so flexible, but it also means **no C struct a
binding works with can ever be described to static analysis or an IDE**. There
is no way to say "this handle is a `zend_string`, these are its fields", and no
way to make `$handle instanceof ZendString` true. `FFI\CData` being `final`
closes off every userland workaround.
This proposes an **opt-in, per-scope class map**: when you create an FFI scope
you may declare that C type `X` should be represented as instances of your class
`\My\X` (class extending `FFI\CData`), so that `FFI::new('X')`,
`FFI::cast('X', …)`, struct-field reads and function returns all produce
`\My\X` instances. Nothing changes for anyone who does not ask for it.
### Motivation — a concrete, load-bearing case study
[z-engine](https://github.com/lisachenko/z-engine) drives the Zend Engine's own
internals through FFI. It dereferences dozens of engine structs —
`zend_string`, `zend_function`, `zend_class_entry`, `zval`, `zend_op_array`, …
— and every one of them is `FFI\CData`. To recover *any* static typing and IDE
autocompletion the project currently has to ship **all of the following**:
1. a code generator that slices each struct out of the PHP headers via clang and
emits one analysis-only PHP stub class per struct (with `@property`/typed
properties mirroring the C fields), https://github.com/lisachenko/z-engine/blob/8.4/stubs/zend-engine-structs.php
2. a `.phpstorm.meta.php` map so PhpStorm resolves the FFI entry points, https://github.com/lisachenko/z-engine/blob/8.4/.phpstorm.meta.php
3. a PHPStan dynamic-return extension so the analyser resolves them too, https://github.com/lisachenko/z-engine/blob/8.4/tools/phpstan/TypedEntryPointReturnExtension.php
4. a hand-maintained convention that every one of those stub classes is
*never loaded at runtime* (they exist only for the analyser), because they
cannot actually back the `CData` handles.
That is four moving parts, per project, to emulate one feature the runtime
could provide directly - and it is strictly weaker than the real thing: the
stub classes can never make `instanceof` work, can never enforce a parameter
type, and drift from the real ABI unless regenerated. Every FFI binding
generator (SWIG-style wrappers, `FFIMe`, hand-written bindings over libgit2,
libsodium, SDL, …) hits the same wall. A native class map solves it once, for
everyone, in ~the same amount of C code these projects spend working around it.
### Proposal
An optional class map attached to an FFI scope, mapping C struct/union **type
names** to userland classes:
The scope takes an optional **`array $options`** configuration, in the spirit of
`SoapServer`/`SoapClient` (which accept a `classmap`, and `SoapClient` also a
`typemap`). Two keys are recognised — `classmap` (C type → userland class) and
`typemap` (C type → conversion callbacks):
```php
$ffi = FFI::cdef($cCode, $lib, options: [
'classmap' => [
'zend_string' => \My\Engine\ZendString::class,
'zend_value' => \My\Engine\ZendValue::class,
],
'typemap' => [
// C type name => how to marshal it to/from PHP (for types that should
// surface as something other than a raw CData handle)
'zend_bool' => [
'from_cdata' => fn(FFI\CData $c): bool => $c->cdata !== 0,
'to_cdata' => fn(bool $v, FFI\CData $c): void => $c->cdata = $v ? 1 : 0,
],
],
]);
final class ZendString extends \FFI\CData
{
// Fields may be exposed as typed property hooks over the raw CData, and the
// class may carry ordinary methods.
public int $len { get => $this->readUint32('len'); }
public function toPhpString(): string { /* ... */ }
}
```
Rules for a `classmap` class:
- it **must extend `FFI\CData`**,
- it **may declare typed property hooks** whose bodies read/write the underlying
C fields through the raw CData, and it **may declare methods** ; the object's storage stays ext/ffi's
`zend_ffi_cdata`, so a hook body operates on the raw structure rather than on a
real backing store.
Given the map, **every handle ext/ffi mints for a mapped C type** — from
`FFI::new()`, `FFI::cast()`, `FFI::addr()`, a struct-field read that yields a
nested struct/pointer, or a function return value — is created as an instance of
the mapped class instead of the bare `FFI\CData`. `get_class()` is truthful,
`instanceof` works, and native parameter/return type declarations
(`function f(ZendString $s)`) are enforced by the engine. Field access, casting,
`FFI::sizeof()`, garbage collection and every other behaviour are **byte-for-byte
identical to today** — the object still *is* a `zend_ffi_cdata`, only its `ce`
differs.
### Implementation sketch
The change is localized to ext/ffi and is zero-overhead when unused:
- **Registry.** Each `zend_ffi` scope gains a `HashTable *class_map` keyed on the
resolved `zend_ffi_type *` (populated from `options['classmap']` at `cdef`/`load`
time by resolving each declared type name to its `zend_ffi_type`, and validating
the target class extends `zend_ffi_cdata_ce`), plus an optional parallel
`typemap` table of conversion callbacks. Both are `NULL`/empty for every
existing user.
- **Minting.** Today every cdata is created with
`object_init_ex(&zv, zend_ffi_cdata_ce)` (in `zend_ffi_cdata_to_zval()` and
the `FFI::new`/`FFI::cast` method handlers). Wrap that single choice: when the
active scope's `class_map` is non-empty, look up the value's
`zend_ffi_type *`, and if a class is registered use it instead of
`zend_ffi_cdata_ce`. One hash lookup, guarded by `class_map != NULL`, so the
common path is unchanged.
- **Layout & lifetime.** The allocated object stays `zend_ffi_cdata`; only the
`std.ce` pointer changes. All `zend_ffi_cdata_handlers` are shared, so GC,
free, clone, and the read/write paths need no changes — this is what keeps the
patch small and safe.
- **Preloading.** For `opcache.preload`ed scopes the map must be re-resolved per
request (the `zend_ffi_type *` pointers are request/persistent-scoped); the
natural place is alongside the existing per-request scope materialization.
- **Struct classes carrying methods / property hooks.** Because the mapped class
is an ordinary `ce` (only the object storage is `zend_ffi_cdata`), methods and
typed property hooks work with no extra machinery — a hook body just reads or
writes the underlying C field through the raw CData.
- **Unchanged:** serialization stays forbidden (as for any cdata).
### Backward compatibility
Fully opt-in and additive. No existing FFI program changes behaviour; the new
`options` array (with its `classmap`/`typemap` keys) is the only surface, and it
defaults to "no mapping". The only relaxation is that `FFI\CData` becomes
extendable *for registered classes only* — a normal `class X extends FFI\CData`
without registration can stay an error (or be allowed as an inert never-minted
class, whichever the RFC prefers).
### Target & offer
I'd like to target **PHP 8.6, ahead of feature freeze**, and I'm volunteering to
write the implementation PR. I'd welcome feedback on the proposal