facebook / hhvm

A virtual machine for executing programs written in Hack.
https://hhvm.com
Other
18.21k stars 3k forks source link

How to use a type parameter in a base class? #5207

Closed tslettebo closed 4 years ago

tslettebo commented 9 years ago

I know that Hack's generics are based on type erasure, so the type parameters are removed in the runtime version of the program, but I'm still wondering if there might be a way to accomplish the following.

In our systems, we have a class, ActiveRecord, which represents the pattern with the same name, and which also contitutes an SQL query builder, used to retrieve an array of objects, whose data is read from the database.

Consider the following code:

<?hh // strict

f();

function f(): void
{
  $user=new User();

  $user_array=$user->get_item_list();

  print_r($user_array);
}

class ActiveRecord
{
  public function get_item_list(): array<User>
  {
    return [new User(),new User(),new User()]; // Result of database query
  }
}

class User extends ActiveRecord
{
}

Here, User is one of many classes inheriting from ActiveRecord, and the get_item_list() method retrieves an array of objects, in this case of the User class. Since get_item_list() can return an array of any kind of (ActiveRecord derived) objects, we can't hardwire "User" in the type declaration like we've done here: array<User>

Is there a way to accomplish this, statically typed, using Hack?

In C++, using the "Curiously Recurring Template Pattern", you could solve it by passing a template parameter to the base class like this:

class User : public ActiveRecord<User> { ... }

However, since you can't use new on a type parameter, or cast to it, in Hack, the following doesn't work:

<?hh // strict

f();

function f(): void
{
  $user=new User();

  $user_array=$user->get_item_list();

  print_r($user_array);
}

class ActiveRecord<T>
{
  public function get_item_list(): array<T>
  {
    return [new T(),new T(),new T()]; // Error, you can't create objects of type T
  }
}

class User extends ActiveRecord<User>
{
}

Suggestions welcome. One possibility could be to implement get_item_list() in each subclass, but that leads to unnecessary duplication.

I've also tried having get_item_list() return array<this>, but it then complains that "this" is not compatible with "User".

tslettebo commented 9 years ago

Using polymorphism, I found one possible solution, adding a "factory method" in the subclass:

<?hh // strict

f();

function f(): void
{
  $user=new User();

  $user_array=$user->get_item_list();

  print_r($user_array);
}

abstract class ActiveRecord<T>
{
  public function get_item_list(): array<T>
  {
    return [$this->make()]; // Result of database query
  }

  protected abstract function make(): T;
}

class User extends ActiveRecord<User>
{
  protected function make(): User
  {
    return new User();
  }
}
jwatzman commented 9 years ago

Factories are one way to do this. However, I think this is better suited for an upcoming feature, type constants. (We're still working through some details, so haven't really talked much about it yet, but there will eventually be docs, a blog post, etc etc.) The idea is that ActiveRecord isn't really generic in the way that, say, Vector is. For the latter, the type parameter is fixed by the calling code, and so can be anything. For the former, the type parameter is fixed by the subclass, and will always be the same for a given subclass. This means we can encapsulate it better, and furthermore might actually be able to have runtime support for new on it (not implemented yet).

Syntax is something like this. (Warning, uses experimental and partially-implemented features, typed directly into my browser, syntax and behavior very very much subject to change, assuming I remembered it correctly in the first place!!)

<?hh // strict

abstract class ActiveRecord {
  abstract type const Record as ActiveRecord;

  public function get_item_list(): ImmVector<Record> {
    // Since Record is fixed by the subclass, we can do a similar sort of LSB
    // lookup as "new static" would do, and so you can in principle do this
    // (though it's not implemented yet):
    return ImmVector { new Record(), new Record(), new Record() };
  }

  // ...
}

class User extends ActiveRecord {
  type const Record = User;
  // ...
}
tslettebo commented 9 years ago

Thanks for your reply regarding this upcoming feature.

Fortunately, using the current version of Hack and final classes, the factory method can be identical in all subclasses of ActiveRecord:

final class User extends ActiveRecord<User>
{
  <<__Override>> protected function make(): this
  {
    return new self();
  }
}
tslettebo commented 9 years ago

Since $this in the base class actually has the right type (User in this case), I figured maybe this could work, obviating the need for a factory method or type constant, alas, it doesn't work:

abstract class ActiveRecord<T>
{
  public function get_item_list(): array<T>
  {
    invariant($this instanceof T,"");

    return [clone $this]; // Result of database query
  }
}

The invariant gives this error message: "Generics can only be used in type hints since they are erased at runtime."

Yet, if you e.g. try to cast to T, it suggests using invariant in exactly this way:

"Object casts are unsupported. Try 'if ($var instanceof T)' or 'invariant($var instanceof T, ...)'"

Maybe T here refers to an arbitrary type.

I think this will do, as it avoids the need for the factory method:

abstract class ActiveRecord<T>
{
  public function get_item_list(): array<T>
  {
    /* HH_FIXME[4110] Invalid return type */

    return [clone $this]; // Result of database query
  }
}

The runtime produces the expected result, and it passes the type checker. What's important is that the method has the right signature, array<T>, so that any code using it may be statically checked.

jwatzman commented 9 years ago

Yeah you can't do instanceof T for the same reason you can't do new T since the runtime doesn't know what T is due to erasure. Feel free to file a new issue for the misleading error message there.

For your clone $this example, isn't that returning an array<ActiveRecord<T>>, not an array<T>? Creating a new this is supported, via new static for example, but isn't the same as the problem in your original post (creating just a new T).

tslettebo commented 9 years ago

For your clone $this example, isn't that returning an array<ActiveRecord<T>>, not an array<T>?

If you look at the definition of User, you'll see it's defined like this:

final class User extends ActiveRecord<User>

Therefore, if you instantiate a User object, $this in the base class will be of type User, as will the type variable T, so it's actually correct. :)

Creating a new this is supported, via new static for example, but isn't the same as the problem in your original post (creating just a new T).

Ah, right. In fact, I found that since T is User, and $this is an instance of User, I can do it like this:

abstract class ActiveRecord
{
  public function get_item_list(): array<this>
  {
    return [clone $this]; // Result of database query
  }
}

This way, the class doesn't have to be generic, and no factory method or type constant is needed.

Alternativly, as you suggest, new static() could be used here, and then <<__ConsistentConstruct>>needs to be added to ActiveRecord. .

lexidor commented 4 years ago

In modern version of hhvm, you can use type constants and classname. The things you need are:

You can select your database rows and call $entity_classname::fromDBRow($db_row). Hack will understand that the result of this call will be the LSB object type that the record is for. You do need the intermediary variable $entity_classname, because static::ENTITY_CLASSNAME::fromDBRow() is not valid Hack.

function query_fake_database(string $table): vec<dict<string, mixed>> {
  switch ($table) {
    case 'programming':
      return vec[
        dict['id' => 1, 'name' => 'HHVM'],
        dict['id' => 2, 'name' => 'Hack'],
        dict['id' => 3, 'name' => 'XHP'],
      ];
    case 'alphabet':
      return vec[
        dict['id' => 1, 'letter' => 'A'],
        dict['id' => 2, 'letter' => 'B'],
        dict['id' => 3, 'letter' => 'C'],
      ];
    default:
      invariant_violation('Table %s not defined', $table);
  }
}

interface Entity {
  public static function fromDBRow(KeyedContainer<string, mixed> $row): this;
}

abstract class ActiveRecord {
  abstract const type TEntity as Entity;
  abstract const classname<this::TEntity> ENTITY_CLASSNAME;
  abstract const string TABLE_NAME;

  public function getItemList(): vec<this::TEntity> {
    $result = /*await*/ query_fake_database(static::TABLE_NAME);

    $entity_classname = static::ENTITY_CLASSNAME;
    $out = vec[];
    foreach ($result as $row) {
      $out[] = $entity_classname::fromDBRow($row);
    }
    return $out;
  }
}

final class ProgrammingRecord extends ActiveRecord {
  const type TEntity = ProgrammingEntity;
  const classname<this::TEntity> ENTITY_CLASSNAME = ProgrammingEntity::class;
  const string TABLE_NAME = 'programming';
}

final class LetterRecord extends ActiveRecord {
  const type TEntity = LetterEntity;
  const classname<this::TEntity> ENTITY_CLASSNAME = LetterEntity::class;
  const string TABLE_NAME = 'alphabet';
}

final class ProgrammingEntity implements Entity {
  public function __construct(public int $id, public string $name) {}
  public static function fromDBRow(KeyedContainer<string, mixed> $row): this {
    return new static($row['id'] as int, $row['name'] as string);
  }
}

final class LetterEntity implements Entity {
  public function __construct(public int $id, public string $letter) {}
  public static function fromDBRow(KeyedContainer<string, mixed> $row): this {
    return new static($row['id'] as int, $row['letter'] as string);
  }
}

<<__EntryPoint>>
function main(): void {
  $programming_record = new ProgrammingRecord();

  foreach ($programming_record->getItemList() as $programming_entity) {
    \printf("Programming: ID(%d), %s\n", $programming_entity->id, $programming_entity->name);
  }

  echo \PHP_EOL;

  $letter_record = new LetterRecord();
  foreach ($letter_record->getItemList() as $letter_entity) {
    \printf("Alphabet: ID(%d), %s\n", $letter_entity->id, $letter_entity->letter);
  }
}
$hhvm src/activerecord.hack 
Programming: ID(1), HHVM
Programming: ID(2), Hack
Programming: ID(3), XHP

Alphabet: ID(1), A
Alphabet: ID(2), B
Alphabet: ID(3), C