Interfaces mit Konstruktoren

PHP erlaubt uns manchmal Dinge, die wir besser nicht tun sollten. Deshalb haben wir oft eine Diskussion mit anderen Entwicklern an der Backe, die uns sagen: "wenn mir PHP das erlaubt, dann mache ich es auch". Hier hilft es, gute Argumente zu haben, warum man etwas eben nicht tun sollte.

Ein sehr gutes Beispiel hierfür ist die Frage, ob wir in einem Interface Konstruktoren deklarieren sollten. Technisch ist das erst mal möglich:

<?php declare(strict_types=1);

interface SomeInterface
{
    public function __construct(int $a, string $b);

    public function doWork(): void;
}
SomeInterface.php

Wenn wir dieses Interface implementieren wollen, dann müssen wir sowohl den Konstruktor als auch die Methode doWork() implementieren:

<?php declare(strict_types=1);

class SomeImplementation implements SomeInterface
{
    private int    $a;
    private string $b;

    public function __construct(int $a, string $b)
    {
        $this->a = $a;
        $this->b = $b;
    }

    public function doWork(): void
    {
        var_dump('doing work');
    }
}
SomeImplementation.php

Jetzt können wir das Objekt instantiieren:

<?php declare(strict_types=1);

require __DIR__ . '/autoload.php';

$object = new SomeImplementation(1, 'the-string');
$object->doWork();
createObject.php
SomeImplementation.php:16:
string(10) "doing work"
Ausgabe von createObject.php

Wo liegt jetzt das Problem? Funktioniert doch alles?

Lose Kopplung

Das Problem ist, dass Interfaces dazu da sind, lose Kopplung zu erreichen. Anstelle mich von einer konkreten Klasse abhängig zu machen, mache ich mich lediglich von einer Schnittstelle abhängig. Daher verwenden wir normalerweise als Type Hints Schnittstellen (Interfaces) anstelle von konkreten Klassen. Eine wichtige Ausnahme von dieser Regel sind übrigens Wertobjekte, für die wir im Allgemeinen keine Interfaces definieren.

Wenn wir in einer Schnittstelle eine Konstruktor-Methode deklarieren, dann zementieren wir damit Annahmen darüber, wie ein Objekt erzeugt werden muss. Im Beispiel oben sind das ein Integer als erster und ein String als zweiter Parameter.

Normalerweise ist PHP relativ entspannt, wenn wir die Signatur eines Konstruktors ändern:

<?php declare(strict_types=1);

class BaseClass
{
    private int    $a;
    private string $b;

    public function __construct(int $a, string $b)
    {
        $this->a = $a;
        $this->b = $b;
    }
}

class ChangingSignature extends BaseClass
{
    private array $x;
    private float $y;

    public function __construct(array $x, float $y)
    {
        $this->x = $x;
        $this->y = $y;
    }
}

var_dump(new BaseClass(1, 'the-string'));
var_dump(new ChangingSignature([], 1.5));
changingSignature.php

Hier deklariert die Subklasse eine völlig andere Konstruktor-Signatur als die Basisklasse. Trotzdem funktioniert das, ohne dass PHP meckert:

changingSignature.php:27:
class BaseClass#1 (2) {
  private int $a =>
  int(1)
  private string $b =>
  string(10) "the-string"
}
changingSignature.php:28:
class ChangingSignature#1 (4) {
  private int $a =>
  *uninitialized*
  private string $b =>
  *uninitialized*
  private array $x =>
  array(0) {
  }
  private float $y =>
  double(1.5)
}
Ausgabe von changingSignature.php

Man kann natürlich darüber diskutieren, inwieweit die Subklasse in diesem Beispiel inhaltlich sinnvoll ist, aber darum soll es hier nicht gehen. Wir fragen uns stattdessen, ob dieses Verändern der Konstruktor-Signatur auch dann noch funktioniert, wenn wir den Konstruktor bereits im Interface festgeschrieben haben?

<?php declare(strict_types=1);

class SomeOtherImplementation implements SomeInterface
{
    private array $x;
    private float $y;

    public function __construct(array $x, float $y)
    {
        $this->x = $x;
        $this->y = $y;
    }

    public function doWork(): void
    {
        var_dump('doing work');
    }
}
SomeOtherImplementation.php

Versuchen wir unser Glück:

<?php declare(strict_types=1);

require __DIR__ . '/autoload.php';

$object = new SomeOtherImplementation([], 1.5);
$object->doWork();
createOtherObject-will-fail.php
PHP Fatal error:  Declaration of SomeOtherImplementation::__construct(array $x, float $y) must be compatible with SomeInterface::__construct(int $a, string $b) in SomeOtherImplementation.php on line 8
PHP Stack trace:
PHP   1. {main}() createOtherObject-will-fail.php:0
PHP   2. {closure:autoload.php:6-27}($class = 'SomeOtherImplementation') createOtherObject-will-fail.php:5
PHP   3. require() autoload.php:25
Ausgabe von createOtherObject-will-fail.php

Natürlich funktioniert das nicht, weil das Objekt SomeOtherImplementation die Schnittstelle nicht erfüllt. Das hat uns ja - je nach Konfiguration - auch schon die IDE gesagt:

Wir schränken uns also unnötig ein, wenn wir eine Konstruktor-Methode zu einem Teil eines Interfaces machen. Wir könnten auch sagen: Objekterzeugung ist ein "separate Concern".

Unterschiedliche Implementierungen

Meistens ist es ja genau so, dass die verschiedenen Implementierungen einer Schnittstelle unterschiedliche Abhängigkeiten haben. Nehmen wir als Beispiel eine Schnittstelle, mit der wir Something von irgendwoher laden können:

<?php declare(strict_types=1);

interface SomethingReaderInterface
{
    public function findById(string $id): Something;
}
SomethingReaderInterface.php

Falls wir Something aus einer Datenbank laden wollen, brauchen wir dazu eine Datenbankverbindung. Also:

<?php declare(strict_types=1);

class DatabaseSomethingReader implements SomethingReaderInterface
{
    public function __construct(
        private readonly DatabaseConnection $db
    ) {}

    public function findById(string $id): Something
    {
        return new Something($this->db->query('...'));
    }
}
DatabaseSomethingReader.php

Ein anderer Reader dagegen würde völlig andere Abhängigkeiten haben:

<?php declare(strict_types=1);

class HttpSomethingReader implements SomethingReaderInterface
{
    public function __construct(
        private readonly HttpClient $http
    ) {}

    public function findById(string $id): Something
    {
        return new Something($this->http->get('...'));
    }
}
HttpSomethingReader.php

Um wirklich deutlich zu machen, dass die beiden Reader austauschbar sind, bauen wir uns ein Stück Code, das nach dem Zufallsprinzip beide abwechselnd benutzt:

<?php declare(strict_types=1);

require __DIR__ . '/autoload.php';

$db = new DatabaseConnection;
$readers[] = new DatabaseSomethingReader($db);

$http = new HttpClient;
$readers[] = new HttpSomethingReader($http);

for ($i = 0; $i < 5; $i++) {
    var_dump($readers[random_int(0, 1)]->findById('the-id'));
}

readSomething.php
readSomething.php:12:
class Something#6 (1) {
  private readonly array $state =>
  array(0) {
  }
}
readSomething.php:12:
class Something#6 (1) {
  private readonly array $state =>
  array(0) {
  }
}
readSomething.php:12:
class Something#6 (1) {
  private readonly array $state =>
  array(0) {
  }
}
readSomething.php:12:
class Something#6 (1) {
  private readonly array $state =>
  array(0) {
  }
}
readSomething.php:12:
class Something#6 (1) {
  private readonly array $state =>
  array(0) {
  }
}
Ausgabe von readSomething.php

Wenn Du jetzt sagst, dass wir den einzelnen Something nicht mehr ansehen, von welchem Reader sie gelesen wurden: das ist ja genau der Witz an der Sache. Genau das ist die lose Kopplung, die wir hier erreichen wollten.

Wenn wir also im Interface einen Konstruktor deklarieren, machen wir uns selbst das Leben schwer, weil wir nämlich verhindern, dass wir unterschiedliche Implementierungen dieser Schnittstelle mit jeweils unterschiedlichen Abhängigkeiten erstellen können. Jeglicher Versuch, das zu retten, beispielsweise indem wir die Dependency Injection nicht über den Konstruktor machen oder indem wir eine Signatur definieren, die verschiedene Abhängigkeiten als optionale Parameter wild kombiniert, sind nicht praktikabel.

Fazit: Wenn wir lose Kopplung wollen und deshalb eine Schnittstelle verwenden, dann darf diese Schnittstelle keine Restriktionen bezüglich der Objekterzeugung machen.

Deklariere niemals einen Konstruktor in einem Interface.