“Czysty kod” w praktyce – Autoloader w PHP

Książka Roberta C. Martina “Czysty kod” bije rekordy sprzedaży wśród pozycji dotyczących szeroko pojętej informatyki. Wstyd więc żebym jej nie przeczytał, no nie? I zdecydowanie polecam ją każdemu programiście, który chciałby być jak najlepszy w tym, co robi.

Chciałbym tutaj pokazać na konkretnym fragmencie kodu, jak wiele może zmienić stosowanie się do zasad przedstawionych przez Martina. Na przykładzie autoloadera.

Tylko najpierw krótki wstęp, co to w ogóle jest “autoloader”. Otóż: przy każdym żądaniu do serwera uruchamiana jest nowa instancja interpretera PHP. Aby nie musiał on ładować wszystkich plików .php obecnych w projekcie (a potrafią ich być tysiące), lecz tylko te, z których akurat w tym żądaniu skorzysta, używany jest mechanizm autoładowania. Działa to tak, że kiedy interpreter PHP trafi na użycie klasy, której nie zna, odpyta o nią po kolei autoloadery, które ma zarejestrowane. Jeśli któryś z nich zwróci true, oznacza to, że znalazł on plik, w którym dana klasa powinna się znajdować, i dołączył go. W przeciwnym razie interpreter pyta kolejnego autoloadera w kolejce, a dopiero jeśli wszystkie zawiodą – wyrzuca fatal error “Class not found”.

Napisałem sobie kiedyś małą klasę autoloadera, a raczej poprzerabiałem jakąś znalezioną na necie. Wyglądała tak:

<?php

class Autoloader {

    private $namespace;

    private $path;

    private $valid = false;

    public function __construct($namespace, $path = '') {
        $this->namespace = $namespace;
        $this->path = $path;
        $this->valid = true;
    }

    public function autoload($className) {
        // is this autoloader responsible for that particular class?
        if (substr($className,0,strlen($this->namespace)) === $this->namespace) {
            $path = $this->path . str_replace('\\','/', ltrim( $className, '\\')) . '.php';
            if (file_exists($path)) {
                require_once($path);
                return true;
            } else {
                // if not straightforward, maybe try "common" class files
                // eg. "Exceptions" for "NotFoundException";
                $slashPos = strrpos($className,'\\',-1) + 1;
                $base = substr($className,0,$slashPos);
                $classPure = substr($className,$slashPos);
                preg_match_all('/(?:^|[A-Z])[a-z]+/',$classPure,$matches);
                $path = $this->path . str_replace('\\','/', ltrim( $base . end($matches[0]) . 's', '\\')) . '.php';
                if (file_exists($path)) {
                    require_once($path);
                    return true;
                } else {
                    return false; // damn, maybe other autoloaders will find something
                }
            }
        } else {
            return false; // no, let the other autoloaders handle that
        }
    }

    public function register() {
        if ($this->valid) {
            spl_autoload_register(array($this, 'autoload'));
            $this->valid = false; //in order not to register it twice
        }
    }
}

Przykładowe użycie mojego autoloadera wygląda następująco:

<?php
$autoloader = new Autoloader('Micrus\\', __DIR__ . '/vendor/');
$autoloader->register();

Tworzymy nowy autoloader mający obsługiwać wszystkie klasy w przestrzeni nazw “Micrus” i szukający ich w katalogu “vendor”, gdzie klasy są umieszczone w podfolderach zgodnie ze swoją przestrzenią nazw. Oznacza to, że jeśli gdzieś w kodzie będziemy chcieli stworzyć nowy obiekt klasy Micrus\Foo\Bar\MyClass, to powinna się ona znajdować w pliku /vendor/Micrus/Foo/Bar/MyClass.php.

Do tego dochodzi jeszcze pewien mały feature (niezgodny z PSR-4). Otóż jeśli chcemy zgrupować kilka podobnych klas w jednym pliku, możemy zrobić to w bardzo prosty sposób. Mamy na przykład klasy wyjątków, które potrafią być naprawdę malutkie i czasem niewygodnie jest rozbijać je na osobne pliki. Nazywamy je wszystkie używając PascalCase z “Exception” jako ostatnim słowem i umieszczamy w pliku Exceptions.php (z dodatkowym -s). Mój autoloader obsłuży taki przypadek.

<?php
namespace Micrus;

class ConfigFileException extends \Exception { }
class NotFoundException extends \Exception { protected $code = 404; }
class CacheException extends \Exception { }
class RouteNotFoundException extends \Exception { protected $code = 404; }
class InvalidArgumentException extends \Exception { }
class UnauthorisedException extends \Exception { protected $code = 403; }
class ArgvException extends \Exception { }
class DatabaseException extends \Exception { }

Kod autoloadera nie wygląda źle (a przynajmniej widziałem gorsze). Ale pierwsze spojrzenie na metodę “autoload” potrafi przerazić. Jest okropnie nieczytelna, mimo że komentarze objaśniają jej działanie. Potrzeba trochę wysiłku, żeby dowiedzieć się, co konkretnie robi.

Wszyscy uczą, zeby pisać komentarze do kodu. Dużo komentarzy. Nawet jeśli wydaje ci się to bezsensowne, bo przecież rozumiesz, co napisałeś, to musisz brać pod uwagę, że jeśli kod trafi w ręce kogo innego, albo i ty sam zajrzysz do niego rok czy dwa później, to rozszyfrowanie go może już nie być takie proste.

Martin natomiast uczy czego innego. Dobry kod wymaga tylko znikomej liczby komentarzy. Jeśli ich potrzebujesz, by zrozumieć, co się dzieje, znaczy że kod jest nieczytelny. No i komentarze są w pewnym sensie niebezpieczne: zapominamy je pisać, zapominamy je aktualizować, boimy się je usuwać, szybko tracą na ważności...

Spójrzcie na kod autoloadera po serii poprawek:

<?php

class Autoloader
{
    private $namespace;

    private $path;

    private $registered = false;

    public function __construct($namespace, $path = '.')
    {
        $this->namespace = $this->addEndingSlash($namespace, '\\');
        $this->path = $this->addEndingSlash($path, '/');
    }

    private function addEndingSlash($word, $slash = '/')
    {
        $lastCharacter = substr($word, -1);
        return $lastCharacter == $slash ? $word : $word . $slash;
    }

    public function autoload($className)
    {
        if ($this->isNotResponsibleForClass($className)) {
            return false;
        }

        $path = $this->classNameToFilePath($className);
        if (file_exists($path)) {
            require_once($path);
            return true;
        }

        $groupName = $this->classNameToGroupName($className);
        $path = $this->classNameToFilePath($groupName);
        if (file_exists($path)) {
            require_once($path);
            return true;
        }

        return false;
    }

    private function isNotResponsibleForClass($className)
    {
        return substr($className, 0, strlen($this->namespace)) !== $this->namespace;
    }

    private function classNameToFilePath($className)
    {
        return $this->path . str_replace('\\','/', ltrim( $className, '\\')) . '.php';
    }

    private function classNameToGroupName($className)
    {
        $slashPos = strrpos($className,'\\',-1) + 1;
        $base = substr($className,0,$slashPos);
        $classPure = substr($className,$slashPos);
        preg_match_all('/(?:^|[A-Z])[a-z]+/',$classPure,$matches);

        return $base . end($matches[0]) . 's';
    }

    public function register()
    {
        if ($this->registered) { return false; }
        spl_autoload_register(array($this, 'autoload'));
        $this->registered = true;

        return true;
    }
}

Wystarczy zerknąć na funkcję autoload, żeby wiedzieć, co robi. Ba, nawet nie trzeba znać żadnego języka programowania! Toż to niemal czysty angielski: “if this is not responsible for class ‘class name’, return false”. Nie mamy pojęcia jak funkcja to robi, ale widać jak na dłoni co takiego wykonuje.

“Jak” natomiast znajduje się w osobnych, prywatnych metodach. Rozdzieliliśmy zatem od siebie różne warstwy abstrakcji, które wcześniej były zlane w jedną, brzydką funkcję. Bo jeśli ktoś zajrzy do naszego kodu, to najprawdopodobniej nie będzie potrzebował znajomości wszystkiego, co tam naskrobaliśmy, lecz przyjdzie z jakimś jednym konkretnym zadaniem: “ogarnąć jak mniej więcej działa ten autoloader”, “zmienić format nazwy ‘zbiorowego pliku’”, “zmienić kolejność sprawdzania potencjalnych plików” itp. I dlatego rozdzielenie warstw abstrakcji tak bardzo uprości mu pracę.

Na pierwszy rzut oka wydawałoby się sensowniejsze stworzenie metody isResponsibleForClass zamiast jej negacji – isNotResponsibleForClass. Kto normalny pytałby klasę, czy nie jest za coś odpowiedzialna? Odpowiedź “tak, nie jestem” tylko niepotrzebnie by wszystko zagmatwała, prawda? No właśnie niekoniecznie. Użycie tej metody jest tylko jedno i sprowadza się do “jeśli nie jesteś za to odpowiedzialna, to daj sobie spokój”. Zapisanie tego warunku jako if (!$this->isResponsibleForClass($className)) zmniejsza czytelność, ponieważ tego wykrzyknika prawie nie widać. Zawsze zapominamy o wykrzyknikach...

Zanegowanie dopadło też zmienną valid. Zawsze gdy ją widziałem, musiałem się zastanowić, po co ona komu. Kiedy niby autoloader staje się “invalid”? A chodzi przecież o to, że autoloader może być albo jeszcze nie zarejestrowany, albo już zarejestrowany. Czemu więc nie zmienić nazwy tej właściwości na registered? Teraz metoda register() staje się bardzo czytelna: “jeśli jesteś zarejestrowany, to już więcej się nie rejestruj”.

Bazowy namespace powinien kończyć się backslashem, aby uniknąć takich sytuacji, gdy chcemy na przykład przechwycić klasy “Java/...”, a zgarniemy także i “Javascript/...”. Dla zachowania jednorodności, to samo powinno dotyczyć drugiego parametru konstruktora (ścieżki do folderu). Ale po co człowiekowi, który chce użyć naszego autoloadera, kazać się zastanawiać nad tym, czy wymagamy slasha czy nie? Może lepiej po prostu sprawdźmy, czy slash jest na końcu parametru, i doklejmy go, jeśli nie? Dodanie metody addEndingSlash sprawiło, że nie tylko sama klasa jest prostsza, ale także i jej użycie na zewnątrz.

Tym oto sposobem w nowym kodzie jest zdecydowanie mniej zagnieżdżeń, metody są krótsze, a każda zajmuje się dokładnie jedną rzeczą na jednym poziomie abstrakcji.

I jak? Którą wersję wolicie? Która nie marnuje czasu programisty próbującego ją zrozumieć?

“Czysty kod” warto przeczytać, warto do niego wracać i – przede wszystkim – warto stosować się do zawartych w nim rad.

A photo of me

About the author

Hi! I'm Andrea (they/them). I tell computers what to do, both for a living and for fun, I'm also into blogging, writing and photography. I'm trying to make the world just a little bit better: more inclusive, more rational and more just.