php / php/php-src

[FFI] Opt-in mapping of C struct types to userland PHP classes (typed CData handles)

未关闭
#23,229 8 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

还没有人认领这个 Issue。

Extension: ffi Feature Status: Needs Triage
主要语言
C
星标
40.4k
派生
8.2k
平均合并
2 天 15 小时
30 天内合并 PR
103

描述

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 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):

$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.preloaded 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

贡献指南

打开贡献指南

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

调研方向

先在 ext/ffi 中阅读 zend_ffi_cdata_to_zval() 以及 FFI::new()/FFI::cast() 的处理器,然后跟踪通过 cdef/load 创建 scope 以及按请求进行 preload 实例化的过程。实现需要一个经过协商的 classmap/typemap 设计、映射后的 CData 实例,以及对不带选项的 scope 保持不变的行为。

由索引模型根据 Issue 内容生成。

评估

技术栈
c, php
领域
backend
Issue 类型
功能
难度
5/5
预计耗时
一周以上
活跃度
活跃
描述清晰度
基本清楚
新手友好度
38/100

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。