Тайп-хинтинг по всем канонам полиморфизма в старых версиях PHP

в 10:39, , рубрики: php, ооп, полиморфизм

tl;dr Вкратце, в данной статье я создам трейт, позволящий даже в версиях PHP младше 5.6 (до версии 5.4) добиться от компилятора поведения, подобного любому статическому языку программирования. Причём трейт будет валидировать не только входные, но и выходные парамеры тоже. Так сказать, полное погружение в тайп-хинтинг.
Данный трейт вы сможете без проблем подключить и использовать в своих веб-приложениях.

Тайп-хинтинг в PHP версии старше 7.0

PHP версии < 7 позволяет в определении метода описать, какие типы данных будут поступать в функцию, и выходной тип данных функции.

Здесь всё замечательно: что задал, то и пришло; что задал, то и вышло.

public function filterArray(array $arr, string $filterParameter, callable $filterCallback) : array

Надо нам определить своё правило фильтрации массива – взяли и создали лямбда-функцию, определили в ней своё правило фильтрации. А в filterArray() передали $arr, заранее зная, что это массив, а не integer какой-нибудь.

Если вдруг в качестве $filterParameter передадим не string, а object, нам PHP мигом выдаст ошибку парсинга. Мол, мы сиё не заказывали.

Тайп-хинтинг в PHP версии младше 5.6

А вот PHP версии < 5.6 не поддерживает явное указание выходных типов данных:


public function sortArray($arr, $filterParam) : array // <- ошибка парсинга
{
    // ...
}

Также PHP < 5.6 не поддерживает примитивы в качестве входных типов данных, такие как integer, string, float.

Однако некоторые типы можно указать даже на старой версии языка. Например, можно указать, что в функцию будет передан параметр типа array, object, либо экземпляр класса:


/**
 * Class ArrayForSorting
 * Будем предполагать, что это какая-то структура с кучей параметров, которые нам сейчас не важны.
 */
class ArrayForSorting
{
    /**
     * Массив для сортировки.
     * 
     * @var array
     */
    public $arrayForSorting;
    
    /**
     * @construct
     */
    public function __construct($arrayForSorting)
    {
        $this->arrayForSorting = $arrayForSorting;
    }
}

/**
 * Class UserSortArray
 * Класс, сортирующий массивы с помощью раздичных методов: вставки, слияния, пузырька.
 */
class UserSortArray
{
    /**
     * Доступные методы сортировки.
     * 
     * @var object
     */
    public $availableSortingMethods;
    
    /**
     * Сортировка методом вставки.
     * 
     * @param ArrayForSorting $sortArray массив для сортировки, передаётся по ссылке.
     * 
     * @throws UserSortArrayException если метод сортировки не доступен в системе.
     */
    public function insertSort(ArrayForSorting &$sortArray)
    {
        if (false === isset($availableSortMethods->insertMethod)) {
            throw new UserSortArrayException('Insert method for user array sort is not available.');
        }
        
        return uasort($sortArray->arrayForSorting, $availableSortMethods->bubbleMethod);
    }
}

Исходная проблема

Но, извольте. Что делать, если мне потребуется в функцию передавать не array, а, к примеру, double?

И программист может запросто передать в функцию хоть строку, хоть массив, хоть экземпляр любого класса.

Выход в данном случае простой: нужно просто каждый раз самостоятельно проверять входные и выходные параметры на валидность.


class ArraySorter
{
    public function sortArray(array &$sortArray, $userCallback)
    {
        // дабы не нарушать святы принципы полиморфизма, 
        // будем возвращать пустой массив в случае ошибки валидации, 
        // а не false или какой-нибудь -1.
        if (false === $this->validateArray($sortArray)) {
            return []; 
        }
        
        return uasort($sortArray, $userCallback);
    }
    
    private function validateArray($array)
    {
        if (!isset($array) || false === is_array($array)) {
            return false;
        }
        
        return true;
    }
}

Однако страшно даже подумать, сколько раз придётся писать один и тот же код, сводящийся к следующим строчкам:


if (null !== $param && '' !== $param) {
    return false; // или [], или '', или что ещё надо возвратить в случае невалидных параметров
    // либо
    throw new Exception(__CLASS__ . __FUNCTION__ . ": Expected integer, got sting");
}

Очевидное решение проблемы – написание валидатора в трейте, которому в дальнейшем делегировать все проверки типов входных параметров. В случае, если параметр имеет не тот тип, который требовался, парсер тут же бросит исключение.

На выходе мы получаем следующее:

  • Язык становится менее динамически типизированным. Зато принципы ООП также не посылаются куда подальше программистом;
  • Дублирующийся код проверок типов данных выносится в отдельную… сущность, если трейт так можно назвать;
  • Новые валидаторы можно добавлять, не затрагивая структуру других классов.

Трейты по сути своей похожи на protected-методы в плане того, что их можно вызвать из любого класса, в который он импортирован. Но, в отличие от наследования, мы можем подключать сколько угодно трейтов в класс и использовать все его свойства и методы.

Трейты доступны для использования в PHP, начиная с версии 5.4.0.

Весь исходник трейта

З.Ы. Я специально написал валидацию каждого примитива по отдельности, чтобы в дальнейшем была возможность передать в трейт массив со своими дополнительными правилами валидации. Например для integer-а можно провалидировать maxValue, minValue, isNatural, для строк можно валидировать length вместо emptiness и так далее.


<?php

namespace traits;

/**
 * Trait Validator
 * Трейт валидации параметров.
 */
trait Validator
{
    /**
     * Валидация параметров.
     *
     * @param array $validationParams массив правил валидации.
     * Формат : 'тип' => значение. 
     * Если после типа идёт слово 'not_empty' -- идёт проверка параметра на пустоту 
     * (т.е. массив, не содержащий элементов, или пустая строка).
     * В массиве содержатся следующие значения:
     * [
     *     'integer'          => 123,
     *     'string not_empty' => 'hello world!',
     *     'array'            => [ ... ],
     * ]
     *
     * @return bool true если валидация прошла успешно.
     *
     * @throws Exception если метод валидации для типа данных не найден.
     */
    public function validate($validationParams)
    {
        // Либо это массив, либо выбрасываем ошибку.
        $this->validateArray($validationParams);
        
        foreach ($validationParams as $type => $value) {
            $methodName = 'validate' . ucfirst($type); // к примеру validateInteger 
            $isEmptinessValidation = false;
            if ('not_empty' === substr($type, -9)) {
                $methodName = 'validate' . ucfirst(substr($type, 0, -9));
                $isEmptinessValidation = true;
            }

            if (false === method_exists($this, $methodName)) {
                throw new Exception("Trait 'Validator' does not have method '{$methodName}'.");
            }
            
            // Либо возвращает true, либо выбрасывает исключение, одно из двух.
            $this->{$methodName}($value, $isEmptinessValidation);
        }

        return true;
    }

    /**
     * Валидирует строку.
     *
     * @param string $string               валидируемая строка.
     * @param bool $isValidateForEmptiness нужно ли валидировать строку на пустоту.
     *
     * @return bool результат валидации.
     */
    public function validateString($string, $isValidateForEmptiness)
    {
        $validationRules = is_string($string) && $this->validateForSetAndEmptiness($string, $isValidateForEmptiness);
        
        if (false === $validationRules) {
            $this->throwError('string', gettype($string));
        }

        return true;
    }

    /**
     * Валидирует булевую переменную.
     *
     * @param boolean $bool булевая переменная.
     *
     * @return bool результат валидации.
     */
    public function validateBoolean($boolean, $isValidateForEmptiness = false)
    {
        $validationRules = isset($boolean) && is_bool($boolean);

        if (false === $validationRules) {
            $this->throwError('boolean', gettype($boolean));
        }

        return true;
    }

    /**
     * Валидирует массив.
     *
     * @param string $array                валидируемый массив.
     * @param bool $isValidateForEmptiness нужно ли валидировать массив на пустоту.
     *
     * @return bool результат валидации.
     */
    public function validateArray($array, $isValidateForEmptiness)
    {
        $validationRules = is_array($array) && $this->validateForSetAndEmptiness($array, $isValidateForEmptiness);

        if (false === $validationRules) {
            $this->throwError('array', gettype($array));
        }

        return true;
    }

    /**
     * Валидирует объект.
     *
     * @param string $object               валидируемый объект.
     * @param bool $isValidateForEmptiness нужно ли валидировать объект на пустоту.
     *
     * @return bool результат валидации.
     */
    public function validateObject($object, $isValidateForEmptiness)
    {
        $validationRules = is_object($object) && $this->validateForSetAndEmptiness($object, $isValidateForEmptiness);
        
        if (false === $validationRules) {
            $this->throwError('object', gettype($object));
        }
        
        return true;
    }

    /**
     * Валидирует целое число.
     *
     * @param string $integer              валидируемое число.
     * @param bool $isValidateForEmptiness нужно ли валидировать число на пустоту.
     *
     * @return bool результат валидации.
     */
    public function validateInteger($integer, $isValidateForEmptiness)
    {
        $validationRules = is_int($integer) && $this->validateForSetAndEmptiness($integer, false);
        
        if (false === $validationRules) {
            $this->throwError('integer', gettype($integer));
        }

        return true;
    }

    /**
     * Валидирует параметр на установленность и на то, пустой ли параметр.
     *
     * @param string $parameter            валидируемый параметр.
     * @param bool $isValidateForEmptiness нужно ли валидировать параметр (объект, массив, строку) на пустоту.
     *
     * @return bool результат валидации.
     */
    private function validateForSetAndEmptiness($parameter, $isValidateForEmptiness)
    {
        $isNotEmpty = true;
        if (true === $isValidateForEmptiness) {
           $isNotEmpty = false === empty($parameter); 
        }

        return isset($parameter) && true === $isNotEmpty;
    }

    /**
     * Бросает исключение.
     * 
     * @param string $expectedType
     * @param string $gotType
     *
     * @throws Exception в случае ошибки валидации входного параметра.
     */
    private function throwError($expectedType, $gotType)
    {
        $validatorMethodName = ucfirst($expectedType) . 'Validator'; // integer -> IntegerValidator
        
        throw new Exception("Parse error: {$validatorMethodName} expected type {$expectedType}, got {$gotType}");
    }
}

Используется трейт очень просто. В качестве примера реализуем класс Notebook, хранящий в себе методы генерации и получения уникального идентификатора для того, чтобы показать, как можно с помощью данного трейта проверять входные и выходные данные функции.


namespace models;

use traits;
/**
  * Class Notebook
  * Ноутбук с уникальным идентификатором.
  */
class Notebook
{
    use traitsValidator;

    /**
     * Уникальный ID ноутбука.
     *
     * @var string
     */
    private $_uid;
    
    /**
      * @construct
      */
    public function __construct()
    {
        $this->_uid = $this->generateUniqueIdentifier();
    }

    /**
     * Возвращает уникальный ID ноутбука.
     * 
     * @return string
     */
    public function getNotebookUID()
    {
        // Метод validate() трейта принимает на вход массив с параметрами 
        // в стиле 'primitiveName' => $primitiveValue.
        // При этом данный метод можно вызвать как в начале функции, 
        // так и в её конце.
        $this->validate([
            'string not_empty' => $this->_uid,
        ]);

        return $this->_uid;
    }

    /**
     * Генерирует уникальный ID ноутбука.
     *
     * @return string
     */
    private function generateUniqueIdentifier()
    {
        $uniqueIdentifier = bin2hex(openssl_random_pseudo_bytes(40));
        
        // А вот и пример валидации выходных параметров.
        $this->validate([
            'string not_empty' => $uniqueIdentifier,
        ]);
        
        return $uniqueIdentifier;
    }
}

Ещё один пример: класс Pen (простая чернильная ручка, инициализирующаяся с каким-то количеством чернил), выводящий сообщение на экран.

Класс Pen


<?php

namespace models;

use traits;

/**
 * Class Pen
 * Обычная чернильная ручка.
 */
class Pen
{
    use traitsValidator;

    /**
     * Оставшееся количество чернил ручки.
     *
     * @var double
     */
    private $remainingAmountOfInk;

    /**
     * @construct
     */
    public function __construct()
    {
        $this->remainingAmountOfInk = 100;
    }

    /**
     * Выводит сообщение на экран.
     *
     * @param string $message сообщение.
     * 
     * @return void
     *
     * @throws ValidatorException в случае ошибки валидации входных параметров.
     */
    public function drawMessage($message)
    {
        $this->validate([
            'string' => $message,
        ]);
        
        if (0 > $this->remainingAmountOfInk) {
            echo 'Ink ended'; // кончились чернила
        }
        
        echo 'Pen writes message: ' . $message . '<br>' . PHP_EOL;

        $this->remainingAmountOfInk -= 1;
    }
    
    /**
     * Возвращает оставшееся количество чернил.
     *
     * @return integer
     */
    public function getRemainingAmountOfInk()
    {
        $this->validate([
            'double' => $this->remainingAmountOfInk,
        ]);

        return $this->remainingAmountOfInk;
    }
}

Ну а теперь давайте распишем нашу ручку на столе: «Hello World»!


// autoload.php - класс автолоадера.

/**
 * Class Autoloader
 * Класс для автозагрузки классов и трейтов.
 */
class AutoLoader
{
    /**
     * Данный метод подгружает классы.
     *
     * @param string $className путь к классу.
     */
    public static function loadClasses($className)
    {
        $dir = dirname(__FILE__);
        $sep = DIRECTORY_SEPARATOR;
        require_once("{$dir}{$sep}{$className}.php");
    }
}

// Подгружаем классы.
spl_autoload_register([
    'AutoLoader',
    'loadClasses'
]);

// ------------------------------------

// index.php

include_once("autoload.php");

use models as m;

$pen = new mPen();
$pen->drawMessage('hi habrahabr'); // Pen writes message: hi habrahabr

$message = [
    'message' => 'hi im message inside array',
];

try {
    $pen->drawMessage($message); // будет выброшено исключение ValidatorException
} catch (Exception $e) {
    echo 'exception was throwed during validation of message <br>' . PHP_EOL;
}

Вывод:


Pen writes message: hi habrahabr
exception was throwed during validation of message 

Заключение

Вот с помощью такого вот простенького трейта можно из слона сделать си-шарп валидировать входные/выходные параметры функций без копипастинга методов в разных классах.

Я специально не стал прикручивать к методу validate() в примере выше особые параметры валидации, например такие, как минимальное/максимальное значение double-ов или строковых переменных, пользовательские колбэки на валидацию параметров, вывод своего сообщения при выбросе исключения и так далее.

Потому что основной целью статьи было рассказать о том, что технология, позволяющая добиться от языка статичности, есть и легко реализуема она даже на старой версии PHP.

Автор: the_kane_is_alive

Источник



https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js