Skip to content

Add covariance/contravariance to inherited methods #3732

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 5 commits into from

Conversation

morrisonlevi
Copy link
Contributor

@morrisonlevi morrisonlevi commented Jan 4, 2019

Return types are covariant; parameter types are contravariant.

RFC: https://wiki.php.net/rfc/covariant-returns-and-contravariant-parameters

Replaces PR #3712.

@nikic
Copy link
Member

nikic commented Jan 7, 2019

This currently doesn't warn, even though it should:

<?php
namespace Foo;

class A {
    public function foo(B $x) : A {
        return new self;
    }
}

namespace Bar;

class B extends \Foo\A {
    public function foo(\stdClass $x) : B {
        return new self;
    }
}

var_dump((new \Bar\B)->foo(new \stdClass));

(What I really wanted to do here is show the opposite -- I don't think your code handles classes split across namespaces correctly, i.e. it will not verify variance for all of them together.)

uint32_t i;
for (i = 0; i < list->children; ++i) {
zend_compile_top_stmt(list->child[i]);
zend_ast **begin = list->child;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As already mentioned, this code is not going to handle namespaces correctly, especially when using braced namespace Foo { ... } form. You'll want classes that are part of multiple braced (or unbraced, but braced is the more complex case) to be part of the same variance region.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I finally understand what you are saying:

namespace NS1 {
  class A implements B {}
}

namespace NS2 {
  class X implements Y {}
}

The A should be verified after X is declared. Right?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes.


/* by-ref constraints on arguments are invariant */
if (fe_arg_info->pass_by_reference != proto_arg_info->pass_by_reference) {
if (!_check_inherited_parameter_type(fe, fe_arg_info, proto, proto_arg_info)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be passing on the -1 state as well?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or maybe not? I don't really get how or at which point this code interacts with CG(unverified_types). I expected that something would remove a class from that set if verification returns 1 (rather than -1), but don't see it.

@morrisonlevi
Copy link
Contributor Author

Perhaps I made a mistake when putting this into a form that would work in a single file test (or maybe the issue is really when it is split across files), but this fatals for me:

namespace Foo {
  class A {
    function foo(B $x): A {
      return new self();
    }
  }
}

namespace Bar {
  class B extends \Foo\A {
    function foo(\stdClass $x): B {
      return new self();
    }
  }
}

namespace {
  var_dump((new \Bar\B())->foo(new \stdClass()));
}

I get:

Fatal error: Declaration of Bar\B::foo(stdClass $x): Bar\B must be compatible with Foo\A::foo(Foo\B $x): Foo\A

This is because Foo\B cannot be found (and indeed, we never defined it). Maybe your test was slightly different and you copied it in wrong? Or maybe I put it into a single-file incorrectly?

@nikic
Copy link
Member

nikic commented Jan 10, 2019

@morrisonlevi Just checked again, the code I provided (exactly as-is, single file) does not produce an error for me. Your code does error. I guess the different namespace style (semicolon vs brace) matters here.

@morrisonlevi
Copy link
Contributor Author

I verified that in your example it does not emit ZEND_VERIFY_VARIANCE opcodes. Thank you.

zend_ast_list *list = zend_ast_get_list(ast);
zend_ast **begin = list->child;
zend_ast **end = begin + list->children;
zend_ast_list *const list = zend_ast_get_list(ast);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: We don't use postfix const (at least for locals).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This deals with two ranges, and I kept accidentally moving begin. I slapped const on a few variables so that the compiler would catch my mistakes here. I can remove it if we really care.

/* Compile any stmts between the two type decls (or the end) */
for (p = last_decl; p < first_decl; ++p) {
zend_compile_stmt(*p);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this really be here? Our early binding only applies to top-level class declarations, not conditional ones, so I would kind of expect that this also holds for variance checks. On the other hand, I don't see a pressing technical reason why we can't do this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe there is a better way, but this fixed the bug you posted, the one with multiple namespaces without using braces. We need to support variance, even if it's not top-level. Here's another example:

<?php
interface A {
  function m(X $z);
}

if (true) {
  class B implements A {
    function m(Y $z) {} // ?
  }
}

We have a test case for things like this, but I forgot which one it was off the top of my head.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While we do need to support variance checks for it (we certainly need that for everything), what (I think, maybe) we don't need it support is the ability to verify variance for multiple classes at once (with cyclic dependencies). I don't think we can reasonable check variance for both classes together in the example you provided, we'd have to check first A and then B, independently.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I verified that the variance opcodes are delayed correctly for this code:

<?php

interface A {
  function m(X $z): A;
}

if (true) {
  class B implements A {
    function m(X $z): B {
      return new self();
    }
  }
  class C extends B {
    function m(X $z): C {
      return new self();
    }
  }
}

Snippet of opcodes:

 L7    #0     JMPZ                    true                 J5                                       
 L8    #1     DECLARE_CLASS           "b"                                                           
 L13   #2     DECLARE_INHERITED_CLASS "c"                  "B"                                      
 L13   #3     VERIFY_VARIANCE         "b"                                                           
 L13   #4     VERIFY_VARIANCE         "c"                                                           
 L21   #5     RETURN<-1>              1 

Maybe I misunderstood your point, but it seems to work.

@azjezz
Copy link

azjezz commented Jan 19, 2019

I'm wondering whether this case is covered by this RFC :

<?php declare(strict_types=1);

interface TaskInterface {}

interface TaskHandlerInterface {
    public function handle(TaskInterface $task): void;
}

class TaskHandler implements TaskHandlerInterface {
    public function handle(TaskInterface $task): void {
        /* ... */
    }
}

class MultipleTaskHandler implements TaskHandlerInterface {
    private $handler;
    
    public function __construct(TaskHandlerInterface $handler) {
        $this->handler = $handler;
    }
    
    /**
     * Not permitted, but type-safe :/
     */
    public function handle(TaskInterface ...$tasks): void {
        foreach($tasks as $task) {
            $this->handler->handle($task);
        }
    }
}

@theodorejb
Copy link
Contributor

@azjezz I don't think that example is related to contravariance, since it doesn't change the type of the parameter but rather the number of parameters.

TaskHandlerInterface specifies that the handle method must be passed a single TaskInterface argument, but MultipleTaskHandler would allow it to be called with multiple arguments, or no arguments. Is it actually considered type-safe for child methods to support passing more or fewer arguments?

@morrisonlevi
Copy link
Contributor Author

Fewer should be fine, as long as the same number can still be accepted. More should be fine, as long as they are optional. Unless I'm just really tired, the behavior should be permissible.

Return types are covariant; parameter types are contravariant.
This should probably be moved somewhere else and renamed.
This work is based off Nikita's review. Notably:

  - Implement contravariance in terms of covariance
  - Improve support for non-consecutive type decls
  - Adds _inheritance_status enum
@TerjeBr
Copy link

TerjeBr commented Feb 20, 2019

Hi @morrisonlevi . Thank you for your great work on this.

I am waiting in eager anticipation for this to land, and I wonder if you could please give a rough estimate of when it will be ready?

Anyway, keep up the good work, I appreciate it.

@nikic
Copy link
Member

nikic commented Mar 4, 2019

I've just reverted the compile-time parent check in deb44d4, because it breaks Mockery. Due to the issue with parent in traits this patch needs to deal with unresolved parent anyway, so I don't think this is problematic.

@davedevelopment As a heads up, it would be good to not generate this code https://github.com/mockery/mockery/blob/4324afeaf9d95b492507e6587abb3f024e2576de/library/Mockery/Mock.php#L603 if there is no parent class, as I expect we'll want to make it a compile-error at some point, even if not in 7.4.

@Majkl578
Copy link
Contributor

Majkl578 commented Mar 8, 2019

@TerjeBr The RFC was accepted for PHP 7.4 so unless some serious problem appears it can be expected to land there.

@nikic
Copy link
Member

nikic commented May 8, 2019

A fun failing case:

<?php
  
$b = new B;
echo "foobar\n";
return;

class A {
    public function test(): X {}
}
class B extends A {
    public function test(): Y {}
}

This will happily create the object without ever verifying variance. The reason is that the classes are early-bound, but the verification is still only performed at the point of definition. We'd have to move the verification to the start of the op array or something -- ideally we'd really not early-bind in this case.

@nikic
Copy link
Member

nikic commented Jun 17, 2019

Superseded by #4185 and #4194.

@nikic nikic closed this Jun 17, 2019
@madonzy
Copy link

madonzy commented Jan 28, 2020

Guys, I can see that return types overloading is implemented correctly in PHP 7.4, but why this code throws the fatal error? ProductRequestDataInterface is the sub-type of RequestDataInterface, but it's still wrong? Why?

<?php

interface RequestDataInterface {}
interface ProductRequestDataInterface extends RequestDataInterface {}

interface HandlerInterface {
    function handle(RequestDataInterface $param);
}

class Handler implements HandlerInterface {
    // Fatal error: Declaration of 
    // Handler::handle(ProductRequestDataInterface $param)
    // must be compatible 
    // with HandlerInterface::handle(RequestDataInterface $param)
    function handle(ProductRequestDataInterface $param) {
        // whatever
    }
}

@nikic
Copy link
Member

nikic commented Jan 28, 2020

@TerjeBr
Copy link

TerjeBr commented Jan 28, 2020

@madonzy Arguments to methods are contravariant:
It is type safe to allow an overriding method to accept a more general argument than the method in the base class.
But it is not type safe to accept a more specific argument in the overriding method.

@madonzy
Copy link

madonzy commented Jan 29, 2020

@nikic @TerjeBr Thank you, guys. Now everything is clear :)

@daniyalhamid
Copy link

Hi guys,

In the documentation it says:

A type declaration is considered more specific ... (when) float is changed to int

Then how come the following throws an error:

class A
{
    public function foo(): float
    {
        // ...
    }
}

class B extends A
{
    public function foo(): int
    {
        // ...
    }
}

Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

9 participants