Hacklang — does this type error involving `this` suggest that the underlying object type matters?

166 views Asked by At

I figured the following examples would be typesafe: in all three cases, I am trying to instantiate an A that expects this, but without much luck:

<?hh // strict
class A {
    public function __construct(?this $next = null) {}
}


// Attempt 1: infer from return type
function foo(): A {
    return new A(foo());
}

// Attempt 2: infer from argument type
function bar(A $A): void {
    new A($A);
}

class B {
    // Attempt 3: infer from property type
    public ?A $A;
    public function baz(): void {
        new A($this->A);
    }
}

Because in all cases the typechecker complains that:

The late-bound type of [the A from which the constructor argument originates] needs to be exactly A.

Since A is not final this might be an instance of a child class.

The only time it doesn't is if an A is instantiated within the same scope:

class B {
    public function foo(): void {
        new A(new A());
    }
}

I'm guessing that the underlying reason is that the underlying object in all of the failed cases might be a child instance upcast to an A? My main confusion is why this renders the instantiation (or, in general, any method call) unsound.

2

There are 2 answers

1
Janos Pasztor On

Quoting from the documentation:

this can only be used as a return type annotation on a method of a class. this signifies that the method returns an object of the same class on which the method is defined.

The primary purpose of return this is to allow chaining of method calls on the instance of the class itself or its subclasses.

In other words, the use of this as a type hint is entirely unsupported and may break in future.

As for a general explanation, this always means the current instance of a class. It does not mean the current type of class. In a static context this is relaxed a bit to mean the current type, but not all subtypes.

Which brings us to the point, your static code is broken:

  1. Attempt 1: foo() is recursively calling itself in an endless loop.
  2. Attempt 2: bar() tries to access a member variable called A which is not nullable and is also not initialized. (Defined in Attempt 3.) Also, you cannot access a non-static context ($this) from a static context.
  3. Attempt 3: Again, you are trying to use a not nullable uninitialized member variable named A. You can't do that in Hack.

If you want your code to work correctly, you will need to get your OO straightened out.

0
concat On

Updated

The more complete answer is allowing subclasses to inherit a this-argumented method is fundamentally flawed. Starting with the basics, function arguments are contravariant, so we can cast them downward because the function can accept any subtypes of the type it specifies, but it can't accept supertypes. So, the cast function(Derived): void to function(Base): void for Derived <: Base is invalid. By extension, the following hierarchy is invalid because Derived <: Base, but the cast of Derived to Base casts foo(...) in the same way:

<?hh // strict
interface Base {
    public function foo(Base $v): void;
}
interface Derived extends Base {
    <<__Override>>
    public function foo(Derived $v): void;
}
// Derived -> Base means foo(Derived): void -> foo(Base): void

Now, note that the this type is doing exactly that, even if Derived doesn't explicitly override foo(...). In other words, the following is exactly equivalent:

<?hh // strict
interface Base {
    public function foo(this $v): void;
}
interface Derived extends Base {}

The simplest violation I can think of:

<?hh
interface Base {
    public function foo(this $v): void;
}
final class Derived implements Base {
    public function bar(): void {}
    public function foo(this $v): void {
        $v->bar(); // trying to call `bar()` on OtherDerived fails miserably!
    }
}
final class OtherDerived implements Base {
    public function foo(this $v): void {}
}
function violate(Base $v, Base $x): void {
    $v->foo($x);
}

bar(new Derived(), new OtherDerived());

However, if we can be sure we have a specific descendant of Base, then we know exactly what its foo(...) wants: its own type, and only its own type. We can apply this in general for the overriding shown in the first code block. This isn't very useful in general, but I assume this is so nice and concise that it was worth allowing to write the interfaces, then checking every invocation.


Original

Without the error, the following violation would be possible:

<?hh // strict
class A {
    public function act(this $v): void {
        $v->act($this);
    }
}
class B extends A {
    public ?int $b_prop;
    <<__Override>>
    public function act(this $v): void {
        $v->b_prop;
    }
}
function initiate(): void {
    violate(new B());
}
function violate(A $v): void {
    (new A())->act($v);
}

I don't know if this is the archetypical violation for this error, but my guess at the rationale:

  1. A::act(this) is public/protected, so by its existence, there exists at least one method with a this argument available to subclasses.
  2. The body of act can call at least $v->act which has a this argument.
  3. act can be called from an A context, so the argument this is exactly A. It follows that this in $v's context is also cast to A.
  4. Because A is not final, it's possible that $v is a proper subtype of A.
  5. The underlying class of $v (in this case, B) can override any this-argumented methods from A under the assumption that this <: B. In this case, $v->b_prop relies on that assumption.
  6. The A context thinks it can pass an A type (e.g. itself) to the B by (3.), but that violates the assumption in (5.)