[Hexlet] PHP: Объектно-ориентированный дизайн

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

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

Fluent Interface
stdClass - встроенный в PHP класс, который автоматически используется при преобразовании типов
Структуры данных - ОО-версии популярных структур данных.
PHPUnit - фреймворк для тестирования в PHP
Collect - библиотека для работы с коллекциями в ОО-стиле
Carbon - библиотека для работы с датами в ОО-стиле
Stringy - библиотека для работы со строками в ОО-стиле

PHP: Объектно-ориентированный дизайн Шаблоны Проектирования

“Шаблоны проектирования” (или “паттерны”) стали неотъемлемой частью современной разработки.

Шаблон проектирования или паттерн (англ. design pattern) в разработке программного обеспечения — повторяемая архитектурная конструкция, представляющая собой решение проблемы проектирования в рамках некоторого часто возникающего контекста. (Wiki)

Простым языком, определение звучит так: типовое решение для типовой задачи. Термин пришел в программирование из архитектуры. В 1970-е годы архитектор Кристофер Александр составил набор шаблонов проектирования, типовых решений для различных архитектурных задач. Спустя полтора десятка лет эта идея была заимствована и адаптирована применительно к разработке графических оболочек языка SmallTalk. Сейчас паттерны встречаются повсеместно, постоянно изобретаются и переизобретаются. Некоторые из них описывают задачи, связанные с небольшим участком кода, другие определяют, например, способы работы в распределенных системах. Причем последние отвязаны от языка программирования. Интересный факт: некоторые шаблоны в языках появились вследствие ограничений самих языков и пытаются обойти их.

Как минимум один паттерн проектирования мы уже знаем по уроку “статические методы”. Его называют Фабрика. Фабрика - функция, порождающая объекты, создание которых сложнее, чем просто передача данных в конструктор.

<?php

class Carbon
{
    public static function now($timezone = '')
    {
        return new self(date("Y-m-d H:i:s"), $timezone);
    }
}

Паттерн фабрика никак не завязан ни на статические методы, ни на объектно-ориентированное программирование, ни на конкретный язык. Ключевое в этом шаблоне - “функция порождает данные”; чем являются данные (хоть объекты, хоть функции - те самые объекты первого рода) или функция - не важно. В статических языках подобные паттерны определяются строже из-за необходимости согласования типов.

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

Насколько важно изучать шаблоны проектирования? Не отрицая полезность, предупрежу, что большинство описаний шаблонов в интернете завязано на конкретную книгу. Далеко не все паттерны, описанные в ней полезны и встречаются в обычной жизни. Многие из них специфицированы под конкретные языки и не применимы в том же виде в других языках. Еще больше паттернов (на порядки) в этой книге не описано.

Как только появились паттерны, то не могли не появиться и антипаттерны. Антипаттерн - такое же типовое решение (потому что им часто пользуются), но создающее больше проблем, чем приносящее пользы. Обычно такие решения возникают из-за непонимания причинно-следственных связей в коде.

С паттернами и антипаттернами мы будем знакомиться на протяжении всех дальнейших курсов.

Дополнительные материалы

  1. Паттерны без привязки к языку
  2. Антипаттерны

PHP: Объектно-ориентированный дизайн Конфигурация

Markdown - упрощенный язык разметки, который удобен при работе с текстом (в отличие от HTML). Браузеры не умеют отображать markdown напрямую, поэтому он транслируется в HTML и уже затем показывается. Трансляция markdown в HTML описывается чистой функцией. Она не зависит от внешнего окружения, детерминирована и не порождает побочных эффектов.

<?php

$html = markdownToHtml($markdown);

На входе текст (в формате markdown), на выходе - тоже текст (в формате html). Если нужно изменить поведение трансляции, то достаточно передать вторым параметром массив опций.

<?php

$html = markdownToHtml($markdown, ['sanitize' => false]);

Теперь давайте вообразим объектно-ориентированную версию этого кода. Перед тем, как двигаться дальше, попробуйте отвлечься от чтения и подумайте над следующими вопросами:

  • Что мы вообще хотим получить такого от ООП, чего не дает нам чистая функция?
  • Как будет выглядеть получившийся интерфейс?

Как вы помните, ключевая идея ООП - абстракция данных. Можно ли сказать, что в процессе преобразования markdown в HTML есть абстракция? Нет. Абстракция подразумевает наличие некоторого понятия (типа), значения которого обладают временем жизни . Это значит, что она создается и затем многократно и по-разному используется. Например, невозможно представить работу с пользователем в виде одной функции. Если говорить о markdown, то конкретный текст этого формата не интересует нас сам по себе, мы не определяем над ним некоторый набор операций и не собираемся им активно пользоваться. Все, что мы хотим, прямо здесь и сейчас (в том коде) - получить HTML и забыть про markdown.

Если бы мы хотели построить вокруг текста абстракцию, то код выглядел бы так:

<?php

$md = new Markdown($markdown);
$html = $md->render();

В примере выше тип Markdown представляет собой абстракцию над текстом в формате markdown. Смысла в таком коде мало, а вот проблем он доставит. Эти две строчки начнут неразрывно встречаться в каждом месте, в котором требуется получить HTML. Объект $md становится сразу не нужен, как только получен HTML, у него нет времени жизни. Такой антипаттерн особенно часто встречается у новичков. Загвоздка здесь именно в том, чтобы разобраться, где у нас абстракция данных, а где нет.

<?php

$md1 = new Markdown($markdown1);
$html1 = $md->render();

$md2 = new Markdown($markdown2);
$html2 = $md2->render();

Существует формальное правило, позволяющее это определить. Если создание объекта и вызов метода можно заменить на обычную функцию, то ни о какой абстракции речи не идет, и правильный подход, в данной ситуации, сводится к переносу данных из конструктора в сам метод.

<?php

$md = new Markdown();
// очень важно, чтобы render оставался чистой функцией и не сохранял $markdown внутри объекта
$html1 = $md->render($markdown1);
$html2 = $md->render($markdown2);

В этом коде Markdown - тип, относящийся к транслятору, а не к тексту. У такого объекта жизненный цикл шире, чем ожидание однократного вызова функции render (как в предыдущем случае). Он может (и должен) переиспользоваться столько раз, сколько потребуется. Для этого важно оставить функцию render чистой и не менять состояние объекта между вызовами.

Тогда становится непонятно, зачем здесь вообще объект. И на это есть 3 причины.

  1. Идиоматика. В PHP, как и в Java, принято практически все оформлять в виде классов. К тому же для них работает автозагрузка.
  2. Полиморфизм подтипов. Разберем в последущих курсах.
  3. Третья и главная причина (для данного случая) - Конфигурация.

Разберем последний пункт подробнее. Представьте что маркдаун на проекте используется повсеместно (на Хекслете очень часто) и код генерации HTML выглядит так:

<?php

// В одном месте
$html1 = markdownToHtml($markdown1, ['sanitaize' => true]);

// Где-то в другом месте
$html2 = markdownToHtml($markdown2, ['sanitaize' => true]);

Чем больше возникает таких мест, тем больше дублируется передача опций. Изменение поведения потребует переписывания всех мест вызова этой функции. Логичным шагом было бы задать опции в одном месте и затем их переиспользовать.

<?php

// В одном месте
$html1 = markdownToHtml($markdown1, $options);

// Где-то в другом месте
$html2 = markdownToHtml($markdown2, $options);

Использование объекта позволяет убрать явную передачу (про которую легко забыть). Суть этого паттерна заключается в конфигурировании. То есть объект в данном случае выступает в роли контейнера, содержащего опции для markdown, которые применяются при рендеринге, что позволяет их не передавать каждый раз.

<?php

$md = new Markdown(['sanitize' => true]);
$html1 = $md->render($markdown1);
$html2 = $md->render($markdown2);

Под конфигурированием, всегда понимается передача опций (различных настроек необходимых данной библиотеке) в конструктор во время создания объекта. Особенно полезным такая конфигурация становится тогда, когда объект создается в одном месте программы (на этапе инициализации приложения), а используется в других местах. Возможность конфигурации не навязывает саму конфигурацию, как правило, подобные объекты можно создавать и без указания чего либо, тогда поведение остается “дефолтным”, но смысл от этого не меняется.

<?php

$md = new Markdown();
$html = $md->render($markdown);

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

Попробуйте проверить себя. Выполнение HTTP запроса это абстракция данных или нет?

<?php

$client = new \GuzzleHttp\Client();
$res = $client->get('https://api.github.com/repos/guzzle/guzzle');
echo $res->getStatusCode();

Данный прием не является прерогативой классов и объектов. В функциональных языках (и в js) он крайне просто реализуется через замыкание

PHP: Объектно-ориентированный дизайн Изменяемая конфигурация

Как мы выяснили в предыдущем уроке, многие объекты в ООП не являются абстракцией данных, а используются как способ сохранить конфигурацию для выполнения повторяющихся действий, таких как генерация HTML из Markdown или определение города по IP. Конфигурация осуществляется через передачу опций в конструктор объекта, а сами опции, хранятся внутри и используются для всех последующих вызовов.

<?php

// timeout устанавливает ограничение в одну секунду на длительность запроса
$ipgeo = new IpGeo(['timeout' => 1000]);
$ipgeo->resolve('123.4.3.2');

Но что если для конкретного запроса, нужно временно установить опции отличные от тех что были переданы в конструктор? Для выхода из этой ситуации есть три возможных решения

Создание нового объекта

Самое простое решение сводится к созданию нового объекта в том месте где нам нужно. Это решение хоть и простое, но обладает рядом недостатков. Главный недостаток связан с невозможностью подмены реализации (тот самый полиморфизм о котором мы будем говорить в будущем), так как объект создается не на этапе конфигурирования системы, а в том месте где происходит вызов. Это, в свою очередь, ведет к тому что придется дублировать общие опции, а тестирование станет затруднительным (если не невозможным).

<?php

$ipgeo = new IpGeo(['timeout' => 10]);
$ipgeo->resolve('123.4.3.2');

Сеттеры

Самый страшный вариант связан с использованием сеттеров.

<?php

$ipgeo = new IpGeo(['timeout' => 1000]);

// В одной части программы
$ipgeo->resolve('123.4.3.2');

// В другой части программы
$ipgeo->setOption('timeout', 10);
$ipgeo->resolve('123.4.3.2');

Изменяемое состояние, самое сложное что есть в программировании. Его наличие приводит практически ко всем сложностям с которыми мы встречаемся и создает трудноотловимые и опасные баги. Догадайтесь, что пойдет не так после выполнения последних двух строк? Наш объект $ipgeo используется совместно всеми частями системы из этого следует что его изменение в одном месте повлияет на все последующие вызовы. В случае работы с Markdown все может быть еще страшнее, так как неправильный вывод порождает дыры в безопасности, а конкретно возможность провести XSS:

<?php

$md = new Markdown(['sanitaize' => true]);

// В одной части программы
$md->render($markdown)

// В другой части программы отключаем санитайз
$md->setOption('sanitize', false);
$md->render($markdown2)

sanitaize , флаг отвечающий за включение безопасного рендеринга. Если его выключить, то теги script вставленные в markdown отобразятся как есть. Такое иногда нужно и допустимо для своего собственного текста (например на Хекслете это уроки), но недопустимо для текста который вводят пользователи. Мутация объекта md создает дыру в безопасности. Избежать ее можно не забыв вернуть опцию обратно:

<?php

$md = new Markdown(['sanitaize' => true]);

// В одной части программы
$md->render($markdown)

// В другой части программы отключаем санитайз
$md->setOption('sanitize', false);
$md->render($markdown2)
$md->setOption('sanitize', true);

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

Новые опции на время запроса

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

<?php

$md = new Markdown(['sanitaize' => true]);

// В одной части программы
$md->render($markdown)

// В другой части программы отключаем санитайз на время выполнения запроса
$md->render($markdown2, ['sanitaize' => false]);
$md->render($markdown3); // sanitaize по прежнему равен true

Теперь все в порядке. Sanitize включен глобально, но в конкретном запросе он был переопределен $md->render($markdown, ['sanitaize' => false]) и это никак не отражается на последующих вызовах метода render .

PHP: Объектно-ориентированный дизайн stdClass

PHP поставляется с небольшим набором предопределенных классов, в который входит stdClass . Этот класс имеет особое значение для языка и используется в некоторых ситуациях автоматически.

Первое, что бросается в глаза: stdClass нарушает стандарт именования и начинается с маленькой буквы. Вторая особеность проявляется при работе с ним - в stdClass нет предопределенных свойств и методов, но объекты этого класса могут наполняться любыми свойствами в процессе работы.

<?php

$obj = new stdClass();
$obj->key = 'value';

var_dump($obj);
// class stdClass#1 (1) {
//   public $key =>
//   string(5) "value"
// }

В обычной ситуации, попытка записать значение в неопределенное свойство приводит к ошибке. В случае с stdClass ошибки не возникает. Такого эффекта можно добиться с помощью специальных магических методов __get и __set . Эти методы вызываются автоматически при обращении к неопределенным свойствам и позволяют создавать их на лету.

<?php

class MyStdClass
{
    private $properties = [];

    public function __set($name, $value)
    {
        $this->properties[$name] = $value;
    }

    public function __get($name)
    {
        return $this->properties[$name];
    }

    // Для полноты полезно реализовать метод __isset
    // http://php.net/manual/ru/language.oop5.overloading.php#object.isset
}

$obj = new MyStdClass();
$obj->key = 'value'; // __set($name, $value) где $name = 'key', а $value = 'value'
$obj->key; // __get($name) где $name = 'key'

print_r($obj);
// MyStdClass Object
// (
//     [properties:Tmp\MyStdClass:private] => Array
//         (
//             [key] => value
//         )
//
// )

Такие объекты очень похожи на ассоциативные массивы, но с объектным синтаксисом. Более того, в JavaScript встроен тип данных object , который ведет себя одновременно и как ассоциативный массив, и как объект из примера выше. В зависимости от желания, к нему можно обращаться и так, и так.

const obj = {};
obj.key = 'value';
obj.key; // value
obj['key']; // value

obj['key'] = 'value2';
obj.key; // value2

Преобразование типов

Преобразование ассоциативного массива в объект приводит к созданию объекта класса stdClass ;

<?php

$userAsArray = [
  'name' => 'George',
  'age' => 18
];

$userAsObject = (object) $userAsArray;

var_dump($userAsObject);
// class stdClass#2 (2) {
//   public $name =>
//   string(6) "George"
//   public $age =>
//   int(18)
// }

Парсинг JSON

В PHP не разделяются понятия массив и ассоциативный массив, что резко отличается от всех остальных языков и форматов. Например, в JSON это два разных типа данных.

{
  "files": ["src/Countable.php", "src/Moment.php"],
  "require": {
    "phpunit": "*",
    "http-client": "*"
  }
}

В JSON files содержит массив, а require - ассоциативный массив. Именно в таких ситуациях и подходит stdClass (хотя, откровенно говоря, это - костыль из-за отсутствия нормальных массивов). Функция json_decode парсит переданный ей JSON и формирует либо массив, либо объект stdClass , в зависимости от того, чем были данные внутри JSON.

stdClass Object
(
    [files] => Array
        (
            [0] => src/Countable.php
            [1] => src/Moment.php
        )

    [require] => stdClass Object
        (
            [phpunit] => *
            [http-client] => *
        )

)

Конфигурация

Многие фреймворки используют stdClass для хранения конфигурации, так как она динамическая (то есть состав ключей меняется в зависимости от потребностей разработчика).

PHP: Объектно-ориентированный дизайн PHPUnit

Практика на хекслете проверяется автоматическими тестами, к которым вы уже немного привыкли, если смогли добраться до текущего урока. Для тестирования PHP-кода мы используем фреймворк PHPUnit, который, хоть и не единственный, но до сих пор - самый популярный. Имея некоторое представление об ООП, мы можем поговорить о его устройстве.

<?php

namespace App\Tests;

use function App\Math\average;
use PHPUnit\Framework\TestCase;

// Единственная незнакомая синтаксическая конструкция в этом тесте - `extends TestCase`.
// С ее помощью реализуется наследование. О наследовании пойдет разговор в следующих курсах, а сейчас достаточно знать, что все методы,
// которые мы вызываем внутри нашего теста, определены в классе `TestCase` и именно наследование позволяет их использовать.

class MathTest extends TestCase
{
    public function testAverage()
    {
        $this->assertEquals(0, average(0));
        $this->assertEquals(5, average(0, 10));
    }
}

Не имеет значения предмет тестирования; любой тест PHPUnit всегда описывается в классе с именем ЧтотоTest внутри директории tests . Если тестируется какой-то конкретный класс с именем Foo , то, по соглашению, его тесты располагаются в классе FooTest . Точно такое же правило с неймспейсами без классов. Как правило, структура папок внутри tests совпадает со структурой исходных файлов - так проще ориентироваться, и некоторые редакторы позволяют автоматически переключаться между тестом и исходным файлом при такой структуре и именовании.

src/                                tests/
`-- Money/Currency.php              `-- Money/CurrencyTest.php
`-- IntlFormatter.php               `-- IntlFormatterTest.php
`-- Money.php                       `-- MoneyTest.php

Каждый тестовый класс состоит из тестовых методов. Тестовые методы всегда начинаются с префикса test , например, testAverage - только тогда PHPUnit понимает, что это тестовый метод и выполняет его автоматически при прогоне тестов. Тестовые методы пишутся программистом. Нет никаких правил в том, сколько их должно быть и какова должна быть их структура. Если нужно написать десять разных тестов на одну функцию, то так и нужно делать.

Главная задача любого тестового метода - выполнить ту проверку, ради которой задумывался соответствующий тест. В примере выше тестируется функция average , находящая среднее арифметическое всех переданных в эту функцию чисел. Average - чистая функция, а значит, ее легко тестировать. Достаточно передать в функцию несколько чисел и проверить возвращаемое значение.

Проверки в PHPUnit выполняются с помощью специальных функций - утверждений. Этих функций довольно много, но среди них есть несколько наиболее используемых. Метод assertEquals принимает на вход два значения:

  1. Expected. Ожидаемый результат - то, что должна вернуть функция.
  2. Actual. Результат, который на самом деле вернула функция.

Порядок важен. На его основе PHPUnit формирует вывод, в котором указывает, что ожидалось, а что пришло на самом деле.

Другое популярное утверждение assertTrueassertFalse ), оно принимает только один аргумент и отлично подходит для тестирования предикатов.

Анализ дизайн

Дизайн тестов на основе классов, теряет свою популярность, а во многих языках уже давно не используется. Современный подход растет из BDD процесса. Синтаксически такие тесты часто полагаются на функции высшего порядка describe и it .

<?php

describe('Example', function () {
    $object = new stdClass();
    $object->name = 'pho';

    context('name', function () use ($object) {
        it('is set to pho', function()  use ($object) {
            expect($object->name)->toBe('pho');
        });
    });
});

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

Дополнительные материалы

  1. Официальная документация
  2. Behat (BDD Framework)
  3. Codeception (Браузерные тесты)
  4. Начинаем писать тесты (Правильно)

PHP: Объектно-ориентированный дизайн DS

PHP поставляется с библиотекой, называемой SPL (Standard PHP Library). Кроме прочего, она содержит набор классов, реализующих популярные структуры данных, таких, как стек или очередь.

<?php

$q = new SplStack();
$q->push(3);
$q->push(10);
$q->pop(); // 10
$q->pop(); // 3

Несмотря на то, что SPL встроен в язык, конкретно к Datastructures есть множество претензий со стороны комьюнити как по производительности, так и по интерфейсам классов. Все это вылилось в создание расширения php-ds (DS). Подробнее о нем читайте в статье https://medium.com/@rtheunissen/efficient-data-structures-for-php-7-9dda7af674cd. php-ds можно установить как обычный пакет https://github.com/php-ds/polyfill. Вся документация доступна здесь: http://docs.php.net/manual/ru/book.ds.php.

DS включает в себя Vector, Deque, Map, Set, Stack, Queue, PriorityQueue, Pair. Эти структуры, в жизни обычного веб-разработчика нужны не каждый день, но все же такое случается. Если вы с ними не знакомы, то рекомендую пробежаться по вики.

Stack

Стек - это коллекция типа “Последний вошел, первый вышел” (Last In, First Out или LIFO), которая позволяет работать только с самым верхним (последним) значением. Итерация происходит от конца к началу с удалением взятого элемента.

<?php

$stack = new Ds\Stack();
$stack->push(3);
$stack->push(10);
$stack->pop(); // 10
$stack->pop(); // 3
print_r($stack->toArray());

Методы pop и push составляют основной интерфейс класса. push добавляет элемент (или элементы) на стек, pop - снимает со стека.

Перепишем с использованием этого стека функцию, которая разбиралась в курсе “PHP: массивы”. Напомню задачу:

Необходимо реализовать функцию, которая проверяет, что парные символы сбалансированы. То есть каждый открывающий символ имеет закрывающий, и они не перекрываются, например, так: [{]} . К таким символам в нашем случае относятся <>, {}, () [] . Входом в функцию может быть ()<>{} . Такой пример проходит проверку, а вот этот уже нет: [({)}] . Здесь происходит перекрытие фигурных и круглых скобок.

<?php

function checkIfBalanced(string $expression): boolean
{
    // инициализируем стек
    $stack = new Ds\Stack();
    // инициализируем список открывающих элементов
    $startSymbols = ['{', '(', '<', '['];
    // инициализируем список пар
    $pairs = ['{}', '()', '<>', '[]'];

    // Проходим по строке от первого до последнего символа
    for ($i = 0; $i < strlen($expression); $i++) {
        $curr = $expression[$i];
        // Если текущий символ находится в списке открывающих символов, то заносим его в стек
        if (in_array($curr, $startSymbols)) {
            $stack->push($curr);
        } else { // Если элемент не входит в список открывающих, то считаем, что это закрывающий символ
            $prev = $stack->pop();
            // Составляем из этих символов пару
            $pair = "{$prev}{$curr}";
            // Проверяем, что она входит в список $pairs. Если входит, то все правильно, двигаемся дальше; если нет,
            // то это автоматически означает, что символы не сбалансированы
            if (!in_array($pair, $pairs)) {
                return false;
            }
        };
    }

    // Если стек оказался пустым после обхода строки, то значит все хорошо
    return sizeof($stack) == 0;
}

По большому счету, ничего не поменялось. Кода не стало меньше, он не стал проще. С другой стороны, такой подход более канонический для PHP.

PHP: Объектно-ориентированный дизайн Collect

В курсе по работе с массивами я упоминал библиотеку Collect, позволяющую манипулировать в объектном стиле. Она содержит более 100 операций, решающих большинство распростаненных задач по манипулированию коллекциями. Крайне рекомендую использовать ее во всех ваших проектах как одну из базовых зависимостей.

Пример ниже показывает, как выполнить flatten , используя Collect. Напомню, что flatten распрямляет массивы, вытаскивая элементы из вложенных на верхний уровень.

<?php

$collection = collect(['name' => 'taylor', 'languages' => ['php', 'javascript']]);
$flattened = $collection->flatten();

// Извлечение массива
$flattened->all(); // => ['taylor', 'php', 'javascript'];

Всего три строчки, но очень много смысла. Попробуем разобраться. В первой строчке создается объект типа Collection . Создается необычным способом — вместо new Collection мы видим обычную функцию. Такой трюк служит одной единственной цели — сделать код компактнее. Это наглядный пример использования паттерна Фабрика.

Объект, который возвращает функция collect , содержит исходную коллекцию внутри себя и предоставляет свой собственный интерфейс для ее изменения. Создав объект, мы можем начать пользоваться самой библиотекой. В примере выше выполняется метод flatten , который возвращает новую коллекцию. Причем под коллекцией понимается не массив, а именно объект типа Collection, что позволяет продолжить обработку без необходимости повторного оборачивания в collect . Кроме того, каждый метод в Collection всегда возвращает новую коллекцию и никогда не модифицирует исходную. Такой подход позволяет переиспользовать промежуточные результаты, не боясь случайно сломать код. В примере выше это означает, что сама $collection не изменилась (и не изменится никогда). А значит, мы можем ее использовать повторно уже для других вычислений.

<?php

$collection = collect(['name' => 'taylor', 'languages' => ['php', 'javascript']]);
$excepted = $collection->except(['name']); // исключаем ключи
$flattened = $collection->flatten();
$collection->all(); // => ['name' => 'taylor', 'languages' => ['php', 'javascript']]
$excepted->all(); // => ['languages' => ['php', 'javascript']]
$flattened->all(); // => ['taylor', 'php', 'javascript']

В последней строчке $flattened->all() из объекта извлекается результирующий массив. Подобный код нужен почти всегда, когда нативная (встроенная в язык) структура оборачивается в объект. Когда все операции выполнены, тогда обычно нам требуется готовый массив для продолжения работы.

Collect содержит внутри себя все те функции высшего порядка, с которыми мы познакомились ранее, это map , filter и reduce .

Map

<?php

$collection = collect([1, 2, 3, 4, 5]);

$multiplied = $collection->map(function ($item, $key) {
    return $item * 2;
});

$multiplied->all();

// [2, 4, 6, 8, 10]

Filter

<?php

$collection = collect([1, 2, 3, 4]);

$filtered = $collection->filter(function ($value, $key) {
    return $value > 2;
});

$filtered->all();

// [3, 4]

Reduce

<?php

$collection = collect([1, 2, 3]);

$total = $collection->reduce(function ($carry, $item) {
    return $carry + $item;
});

// 6

Fluent Interface

Посмотрите на то, как организована цепочка вызовов в коде ниже.

<?php

$result = collect(['taylor', 'abigail', null])->map(function ($name) {
    // переводим в верхний регистр
    return strtoupper($name);
})
->reject(function ($name) {
    // отфильтровываем пустые
    return empty($name);
});

// выводим коллекцию на экран
$result->dump(); // => ['TAYLOR', 'ABIGAIL']

Схематично цепочка выглядит так: $collection->map(...)->reject(...) . Мы уже рассматривали подобный код, когда один объект возвращает другой, но тогда речь шла про то, что объект одного типа возвращает объект другого типа, у которого есть свои методы. В данном же примере методы возвращают объект того же типа (возникает ощущение что возвращается сам объект, но в измененной форме). В теории такой подход дает возможность строить цепочки неограниченной длины: $collection->map(...)->map(...)->map(...) . Такую цепочку вызовов принято называть fluent interface (текучий интерфейс).

Кстати в том же JavaScript такие цепочки — основной способ строить вычисления на коллекциях.

[0, -2, 4].map(n => n ** 2).filter(n => n > 3); // [4, 16]

Query Builder

Query Builder — широко распространенный паттерн проектирования, позволяющий собирать сложные запросы по частям. Чаще всего он встречается при работе с базами данных для сбора sql, либо для коллекций, как в примерах данного урока. Этот паттерн в ОО-языках реализуется с помощью fluent interface.

<?php

// laravel query builder
$price = DB::table('orders')
                ->where('finalized', 1)
                ->avg('price');

Его удобство проявляется особенно сильно в тех местах, где логика построения запросов условная. Например, фильтрация товаров в интернет-магазине. Без Query Builder такую выборку реализовать крайне трудно.

PHP: Объектно-ориентированный дизайн Fluent Interface

Fluent Interface удобен для создания DSL. Domain Specific Language (Предметно-ориентированный язык) — язык, специализированный под конкретную область применения. Структура такого языка отражает специфику решаемых с его помощью задач. Яркий пример подобного языка — библиотека Jquery, с которой знакомо большинство программистов (или хотя бы слышали о ней).

$('#test').css('color', '#333').height(200);

На техническом уровне есть ровно два способа создать такой интерфейс.

This

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

<?php

class Coll
{
    private $coll;

    public function __construct(array $coll)
    {
        $this->coll = $coll;
    }

    public function map(callable $fn)
    {
      $this->coll = array_map($fn, $this->coll);

      return $this;
    }

    public function filter(callable $fn)
    {
      $this->coll = array_filter($this->coll, $fn);

      return $this;
    }

    // Возвращает саму коллекцию, а не this. Этот метод всегда последний в цепочке вызовов Coll.
    public function all()
    {
        return $this->coll;
    }
}



$cars = new Coll([
  ['model' => 'rapid', 'year' => 2016],
  ['model' => 'rio', 'year' => 2013],
  ['model' => 'mondeo', 'year' => 2011],
  ['model' => 'octavia', 'year' => 2014]
]);

$cars->filter(function ($car) { return $car['year'] > 2013; })
     ->map(function ($car) { return $car['model']; });
$cars->all(); // [rapid, octavia]

У этого способа есть один серьезный недостаток — объект изменяется. Это значит, что нельзя взять и просто так переиспользовать объект-коллекцию для разных выборок, потому что они начнут накладываться друг на друга.

На практике часто используется другой подход, с которым мы уже познакомились в прошлом курсе. Все, что нужно сделать — добавить немного функциональности в ооп, то есть возвращать не $this , а создавать новый объект того же типа с обновленной коллекцией.

<?php

class Coll
{
    private $coll;

    public function __construct(array $coll)
    {
        $this->coll = $coll;
    }

    public function map(callable $fn)
    {
      $coll = array_map($fn, $this->coll);

      return new Coll($coll);
    }

    public function filter(callable $fn)
    {
      $coll = array_filter($this->coll, $fn);

      return new Coll($coll);
    }

    // Возвращает саму коллекцию, а не this. Этот метод всегда последний в цепочке вызовов Coll.
    public function all()
    {
        return $this->coll;
    }
}

$cars = new Coll([
  ['model' => 'rapid', 'year' => 2016],
  ['model' => 'rio', 'year' => 2013],
  ['model' => 'mondeo', 'year' => 2011],
  ['model' => 'octavia', 'year' => 2014]
]);

$filteredCars = $cars->filter(function ($car) { return $car['year'] > 2013; });
$mappedCars = $filteredCars->map(function ($car) { return $car['model']; });
$mappedCars->all(); // [rapid, octavia]
$cars->all();
// [
//   ['model' => 'rapid', 'year' => 2016],
//   ['model' => 'rio', 'year' => 2013],
//   ['model' => 'mondeo', 'year' => 2011],
//   ['model' => 'octavia', 'year' => 2014]
// ]

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

self

<?php

class Coll
{
    // ...

    public function map(callable $fn)
    {
      $coll = array_map($fn, $this->coll);

      return new Coll($coll);
    }

    // ...
}

В каждом методе, который участвует в построении Fluent Interface, последняя строчка всегда содержит один и тот же вызов: new Coll($coll) . Ее можно записать проще, не дублируя названия класса. Помните как в прыдудщем курсе использовался self для работы со статическими членами класса? Так вот self работает и с обычными методами, вызов new self($coll) идентичен вызову new Coll($coll) , другими словами вместо self подставляется текущий класс. У такого вызова есть еще одно преимущество, о котором мы поговорим в следующем ООП курсе, в теме наследования. В двух словах self реализуется посредством позднего связывания и при наследовании раскрывается в тот класс, с которым прямо сейчас идет работа.

Дополнительные материалы

  1. Текучий интерфейс

PHP: Объектно-ориентированный дизайн Carbon

Для работы с датами в PHP есть три пути.

Первый вариант подходит только для примитивных рассчетов. Не потому, что функции плохи, а потому, что в PHP их мало и ими неудобно пользоваться (интерфейс для машин, а не для людей). Встроенные классы тоже не сильно облегчают задачу. Они работают на низком уровне и простые операции в стиле “получить дату, которая была неделю назад” становятся целым приключением.

В этом уроке мы рассмотрим третий вариант. Самым популярным сторонним решением для работы с датами в PHP является библиотека Carbon.

<?php

use Carbon\Carbon;

// выдало текущую дату на момент написания урока
printf("Now: %s", Carbon::now()); // Now: 2018-04-21 13:31:56

В целом принцип работы этой библиотеки совпадает с принципом работы Collect . Создавая объект, мы как бы “оборачиваем” дату, делая ее внутренним состоянием объекта. Затем выполняем необходимые операции, используя соответствующие методы. Когда нам снова понадобится дата, то мы сможем ее извлечь.

Библиотека Carbon обширна и включает в себя невероятно большое количество методов. Мы рассмотрим буквально несколько, а за остальными добро пожаловать в официальную документацию.

Определение выходного дня

<?php

if (Carbon::now()->isWeekend()) {
    echo 'Party!';
}

// Без Carbon
// if (date('D') == 'Sat' || date('D') == 'Sun') {
//    echo "Today is Saturday or Sunday.";
// }

Вывод

<?php

Carbon::create(2001, 4, 21, 12)->diffForHumans(); // 1 month ago

Манипулирование датами

<?php

$nextSummerOlympics = Carbon::createFromDate(2016)->addYears(4);
// date("F j Y", mktime(0, 0, 0, 1, 1, 2016 + 4));

Fluent Setters

Carbon предоставляет Fluent интерфейс для генерации дат, причем даже несколько видов таких интерфейсов. Его полезность проявляется в местах, где построение дат - динамическое.

<?php

$dt = Carbon::now();

$dt->year(1975)->month(5)->day(21)->hour(22)->minute(32)->second(5)->toDateTimeString();
$dt->setDate(1975, 5, 21)->setTime(22, 32, 5)->toDateTimeString();
$dt->setDate(1975, 5, 21)->setTimeFromTimeString('22:32:05')->toDateTimeString();
$dt->setDateTime(1975, 5, 21, 22, 32, 5)->toDateTimeString();

$dt->timestamp(169957925)->timezone('Europe/London');

Сравнение дат

Отдельного внимания заслуживает сравнение дат, которое учитывает часовые пояса.

<?php

echo Carbon::now()->tzName;                        // America/Toronto
$first = Carbon::create(2012, 9, 5, 23, 26, 11);
$second = Carbon::create(2012, 9, 5, 20, 26, 11, 'America/Vancouver');

echo $first->toDateTimeString();                   // 2012-09-05 23:26:11
echo $first->tzName;                               // America/Toronto
echo $second->toDateTimeString();                  // 2012-09-05 20:26:11
echo $second->tzName;                              // America/Vancouver

var_dump($first->eq($second));                     // bool(true)
var_dump($first->ne($second));                     // bool(false)
var_dump($first->gt($second));                     // bool(false)
var_dump($first->gte($second));                    // bool(true)
var_dump($first->lt($second));                     // bool(false)
var_dump($first->lte($second));                    // bool(true)

Благодаря наличию Spaceship Operator в PHP версий старше 7.1, код выше можно переписать, используя обычные операции сравнения.

<?php

var_dump($first == $second);                       // bool(false)
var_dump($first != $second);                       // bool(true)
var_dump($first > $second);                        // bool(false)
var_dump($first >= $second);                       // bool(false)
var_dump($first < $second);                        // bool(true)
var_dump($first <= $second);                       // bool(true)

Дополнительные материалы

  1. Carbon Docs

PHP: Объектно-ориентированный дизайн Stringy

PHP долгое время не работал с многобайтовыми кодировками, такими как utf-8.

<?php

strlen('привет'); // 12

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

<?php

mb_strlen('привет'); // 6

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

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

<?php

// Обязательно проверять строго на равенство нулю
strpos('start', 'st') === 0; // true

Такой код, мало того, что сложен (не очевидно, что он делает), так еще и является постоянным источником ошибок из-за неявного приведения типов.

Stringy

Библиотека Stringy предоставляет унифицированный объектно-ориентированный интерфейс для работы со строчками. Она работает как типичный builder, например, Collect.

<?php

use function Stringy\create as s;

s('fòôbàř')->toUpperCase(); // 'FÒÔBÀŘ'

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

  • Цепочки
<?php

s('fòô     bàř')->collapseWhitespace()->swapCase(); // FÒÔ BÀŘ
  • Итерацию с помощью foreach
<?php
$stringy = s('fòôbàř');
foreach ($stringy as $char) {
  echo $char;
}
// fòôbàř
  • Функция count
<?php

$stringy = s('fòô');
count($stringy);  // 3

Кроме того, объект, возвращаемый функцией s , реализует магический метод toString , а это значит, что не придется заниматься преобразованием типов, как в случае с Collect . Каждый раз, когда объект используется как строка, на его месте оказывается строка.

Всего в библиотеке около 100 функций.

Дополнительные материалы

  1. Stringy