ext-php-rs icon indicating copy to clipboard operation
ext-php-rs copied to clipboard

Constructors with subclasses

Open joehoyle opened this issue 3 years ago • 10 comments

I'm not sure it's possible for classes via the php_impl macro to be subclassed, as the constructor will return Self, instead of the subclassed class. I might be misunderstanding how subclassing (from user land PHP) should be working though!

joehoyle avatar May 08 '22 01:05 joehoyle

Indeed, this is not working as of version 0.8.1:

#[php_class]
pub struct Greeter(String);

#[php_impl]
impl Greeter {
  pub fn __construct(who: String) -> Self {
    Self(who)
  }
  
  pub fn greet(&self) -> String {
    format!("Hello, {}!", self.0)
  }
}
<?php

var_dump((new Greeter('world'))->greet()); // => "Hello, world!"

$example = new class("it doesn't work") extends Greeter
{
  public function greet(): string
  {
     return "Hello, it works!";
  }
};
var_dump($example->greet()); // => "Hello, it doesn't work!"

ju1ius avatar Nov 09 '22 17:11 ju1ius

@joelwurtz I wonder if you've run into this issue / found a solution to this issue?

joehoyle avatar Oct 24 '23 16:10 joehoyle

or maybe @danog !

joehoyle avatar Oct 24 '23 16:10 joehoyle

I think the issue is around here: https://github.com/davidcole1340/ext-php-rs/blob/dddc07f587cd5d40f3b3ff64b9a59fba8299d953/src/builders/class.rs#L164C58-L168 where PHP passes a reference to the ce (which would be the subclass), but ext-php-rs ignores the ce provided by PHP and instead takes the static reference for the ce from the registered class' metadata.

joehoyle avatar Oct 24 '23 17:10 joehoyle

@joehoyle Yes that's the issue. The create_object handler should just forward the zend_class_entry pointer to internal_new (the Zend engine guarantees that it points to either your registered class entry or a subclass thereof).

Then this pointer should be passed to zend_object_std_init et al.

ju1ius avatar Oct 24 '23 18:10 ju1ius

@ju1ius ok cool, I had a quick go at trying that, but then ran into panics in the free_obj handler and such so I wasn't sure if I was going in the right direction or not!

joehoyle avatar Oct 24 '23 18:10 joehoyle

Yeah, I tried to fix it sometime ago but ran into so much fundamental issues that I ended up writing my own library from scratch. 🤣

ju1ius avatar Oct 24 '23 18:10 ju1ius

@ju1ius ah I see! Any chance that code is open sourced?

joehoyle avatar Oct 25 '23 07:10 joehoyle

Ok I think I got this working as it happens in #277. I wasn't that familiar with how the ce was linked to the Rust struct, so I wrote up a playthrough as I understood it. If that's valuable to anyone else in the future, I've pasted it below:


Class Entry

In this library, when a struct is marked as #[php_class], this implements the RegisteredClass trait on the struct. This trait adds methods:

  • get_metadata() : &'static ClassMetadata
  • get_properties() : HashMap<String, Property>

ClassMetadata stores the ClassEntry (ce) for the #[php_class] struct.

Classes are registered in PHP with the zend_register_internal_class_ex( ce ) function. This library calls this via the ClassBuilder. ClassBuilder is constructed as part of the #[php_startup] macro, which essentially does:

ClassBuilder::new("MyClass").object_override::<MyClass>().build();

object_override() sets the create_object handler for the object, which essentially sets a custom externed C function as the handler when the class is instantiated from PHP.

The build call will register the class via the aformentioned zend_register_internal_class_ex() and other simliar things like registering class constants, properties etc.

That's essentially everythign that happens to register the class with the PHP runtime ahead of time. All other things are invoked once the class is called / instantiated from PHP.

Instantiation

When PHP user-land code instantiates the object with new MyClass(), the custom create_object is called. This handler creates a ZendClassObject. That's a struct that couples the Rust struct instance MyClass with the ZendObject. ZendObject is the PHP Object, being an instance of the registered PHP class.

So, when the create_object handler is called, a new ZendClassObject is created via ZendClassObject::new_uninit. "New uninit" because the ZendClassObject will have a ZendObject (.std) but not an initialized instance of the Rust struct MyClass yet (.obj).

ZendClassObject::new_internal has some pretty funky magic. To provide a way to get the Rust struct for the ZendObject it manually allocates the memory for the ZendClassObject which is essentially two references: The &ZendObject (std) followed by the &MyClass reference (object) directly after. It's then always possible to get the instance of MyClass my manual memory offsets from the ZendObject if you only have a reference to the ZendObject. That's done via the ZendClassEntry::_from_zend_obj() helper static method.

So, the ZendObject is allocated with zend_object_std_init() and the ce for the ZendObject is taken from MyClass::get_metadata() method.

A reference to the ZendObject is returned from the create_object handler back to the PHP runtime.

Instantiation of the Rust struct happens when the constructor() PHP function is called. ClassBuilder has some boilerplate for the wrapped constructor externed function, which looks up the instance of the ZendClassObject via the ExecuteData.This and sets the new Rust struct in the obj property. The ZendClassObject is now initted.

Method Calling

We haven't talked about how method calling is brided in to Rust. When the PHP class is registered via the ClassBuilder with zend_register_internal_class_ex( ce ), the ClassEntry.ce.info.internal.builtin_functions pointer is pointed to the list of methods (which are moxed) from the ClassBuilder. Each function is a FunctionEntry (zend_function_entry). FunctionEntrys are created via the FunctionBuilder similar to the ClassBuilder.

The #[php_impl] macro is responsible for collecting all methods on the Rust impl and generating a wrapper FunctionEntry for each method. The wrapper accepts the ExecuteData and retval as the function callback, the ZendObject (i.e. PHP $this) is looked up form the ExecuteData. The sketchy memory offset ZendClassEntry::_from_zend_obj() essentially is then looked up and the Rust function is called on the Rust struct instance. All the usual IntoZval conversions are done in this wrapper too.

Inheritance and Sub-Classing

Thoug this isn't currently supported here's what likely needs to happen to make it work. In PHP inheritance this is only a single ZendObject of a Sub-class, so for all intents and purposes, the Sub-class ClassEntry shoulld be swapped via the parent ClassEntry. PHP will already attach the parent ClassEntry to the sub-class's ClassEntry and method / ::parent resolving will be handled automatically.

Because the Sub-class will inherit the create_object overriden callback to the parent class, the same create_object must also handle subclasses. Fortunately the ClassEntry is provided to the create_object handler. It should be a case of instead initting the ZendObject via zend_object_std_init with the ce from create_object.

joehoyle avatar Oct 26 '23 14:10 joehoyle

@ju1ius ah I see! Any chance that code is open sourced?

It is planned yes, but no clear ETA. Probably sometime after the PHP 8.3 release.

ju1ius avatar Oct 26 '23 14:10 ju1ius