Статьи

Практический PHP-рефакторинг: дразнить кроме наследования

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

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

Зачем устранять некоторые уровни наследования?

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

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

/ Случай, который мы хотим рассмотреть сегодня, — это комбинаторные взрывы проблем, когда каждый уровень подклассов добавляет измерение ответственности класса.
Рассмотрим, например, Post и Link как подклассы NewsFeedItem. На втором уровне мы можем добавить классы FacebookPost и TwitterPost, унаследованные от Post. Но также TwitterLink и, может быть, завтра FacebookLink. Если мы поддерживаем LinkedIn, давайте подумаем о LinkedInPost … Количество классов растет с квадратом участвующих элементов.

Существует несколько решений (например, шаблон Decorator), но наша цель всегда одна и та же: сохранить как можно меньшую иерархию , допуская одну категоризацию. Когда классы уникальной иерархии могут быть помещены в матрицу, они растут на порядок больше, чем при наличии одного уровня наследования.
Фаулер объясняет, как 3D или 4D матрица еще хуже, и требует, чтобы этот рефакторинг применялся больше раз к каждой паре измерений. Если вы когда-либо видели пару классов WithImageFacebookPost и TextualFacebookPost …

меры

  1. Во-первых, определите различные проблемы для разделения: все измерения, на которые вы можете поместить классы. В нашем продолжающемся примере мы говорим о категоризации SocialNetwork и Item.
  2. Выберите одно из двух измерений, чтобы сохранить его в текущей иерархии, а другое будет извлечено.
  3. Выполните Извлечение класса на базовом классе иерархии, чтобы представить коллаборатора. Это будет базовый класс другой иерархии.
  4. Введите подклассы для извлеченного объекта , для каждого из исходных, которые должны быть исключены (поэтому мы говорим только о второй классификации). Инициализируйте текущие объекты с ними.
  5. Переместите методы на новую иерархию. Возможно, вам придется извлечь их в первую очередь.
  6. Если подклассы содержат только инициализацию, переместите логику в код создания и удалите их.

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

пример

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

В исходной ситуации существует иерархия с двумя уровнями:

 

 

NewsFeedItem определяет два абстрактных метода, content () и authorLink (), которые используются для отображения себя. Post и Link реализуют content (), в то время как их подклассы реализуют authorLink () для Facebook и Twitter соответственно.

<?php
class TeaseApartInheritance extends PHPUnit_Framework_TestCase
{
    public function testAFacebookPostIsDisplayedWithTextAndLinkToTheAuthor()
    {
        $post = new FacebookPost("Enjoy!", "PHP-Cola");
        $this->assertEquals("<p>Enjoy!"
                          . " -- <a href=\"http://facebook.com/PHP-Cola\">PHP-Cola</a></p>",
                            $post->__toString());
    }

    public function testAFacebookLinkIsDisplayedWithTargetAndLinkToTheAuthor()
    {
        $link = new FacebookLink("Our new ad", "http://youtube.com/...", "PHP-Cola");
        $this->assertEquals("<p><a href=\"http://youtube.com/...\">Our new ad</a>"
                          . " -- <a href=\"http://facebook.com/PHP-Cola\">PHP-Cola</a></p>",
                            $link->__toString());
    }

    public function testATwitterLinkIsDisplayedWithTargetAndLinkToTheAuthor()
    {
        $link = new TwitterLink("Our new ad", "http://youtube.com/...", "giorgiosironi");
        $this->assertEquals("<p><a href=\"http://youtube.com/...\">Our new ad</a>"
                          . " -- <a href=\"http://twitter.com/giorgiosironi\">@giorgiosironi</a></p>",
                            $link->__toString());
    }
}

abstract class NewsFeedItem
{
    protected $author;

    public function __toString()
    {
        return "<p>"
             . $this->content()
             . " -- "
             . $this->authorLink()
             . "</p>";
    }

    /**
     * @return string
     */
    protected abstract function content();

    /**
     * @return string
     */
    protected abstract function authorLink();
}

abstract class Post extends NewsFeedItem
{
    private $content;

    public function __construct($content, $author)
    {
        $this->content = $content;
        $this->author = $author;
    }

    protected function content()
    {
        return $this->content;
    }
}

abstract class Link extends NewsFeedItem
{
    private $url;
    private $linkText;

    public function __construct($linkText, $url, $author)
    {
        $this->linkText = $linkText;
        $this->url = $url;
        $this->author = $author;
    }

    protected function content()
    {
        return "<a href=\"$this->url\">$this->linkText</a>";
    }
}

class FacebookPost extends Post
{
    protected function authorLink()
    {
        return "<a href=\"http://facebook.com/$this->author\">$this->author</a>";
    }
}

class TwitterLink extends Link
{
    protected function authorLink()
    {
        return "<a href=\"http://twitter.com/$this->author\">@$this->author</a>";
    }
}

class FacebookLink extends Link
{
    protected function authorLink()
    {
        return "<a href=\"http://facebook.com/$this->author\">$this->author</a>";
    }
}

As you can see, the second level introduces some duplicated code that a composition solution will immediately fix. We choose to maintain Post and Link in the curent hierarchy, since they are also tied to $this->author. The new hierarchy will contain a Facebook and a Twitter related class.

We add the Source new base class for the second hierarchy, and a field in NewsFeedItem to hold an instance of it:

abstract class NewsFeedItem
{
    protected $author;
    protected $source;

    public function __toString()
    {
        return "<p>"
             . $this->content()
             . " -- "
             . $this->authorLink()
             . "</p>";
    }

    /**
     * @return string
     */
    protected abstract function content();

    /**
     * @return string
     */
    protected abstract function authorLink();
}

abstract class Source
{
    public function __construct($author)
    {
        $this->author = $author;
    }

    public abstract function authorLink();
}

We add the two FacebookSource and TwitterSource subclasses, and we initialize the $source field to the right instance via a init() hook method. A constructor would be equivalent, but right now we would have to delegate to parent::__construct() and that would be noisy.

class FacebookSource extends Source
{
    public function authorLink()
    {
    }
}

class TwitterSource extends Source
{
    public function authorLink()
    {
    }
}

class FacebookPost extends Post
{
    public function init() { $this->source = new FacebookSource($this->author); }

    protected function authorLink()
    {
        return "<a href=\"http://facebook.com/$this->author\">$this->author</a>";
    }
}

class TwitterLink extends Link
{
    public function init() { $this->source = new TwitterSource($this->author); }

    protected function authorLink()
    {
        return "<a href=\"http://twitter.com/$this->author\">@$this->author</a>";
    }
}

class FacebookLink extends Link
{
    public function init() { $this->source = new FacebookSource($this->author); }

    protected function authorLink()
    {
        return "<a href=\"http://facebook.com/$this->author\">$this->author</a>";
    }
}

We perform Move Method two times to move the authorLink() behavior in the collaborator. This means we have to delegate to $this->source in the base class NewsFeedItem.

abstract class Source
{
    public function __construct($author)
    {
        $this->author = $author;
    }

    public abstract function authorLink();
}

class FacebookSource extends Source
{
    public function authorLink()
    {
        return "<a href=\"http://facebook.com/$this->author\">$this->author</a>";
    }
}

class TwitterSource extends Source
{
    public function authorLink()
    {
        return "<a href=\"http://twitter.com/$this->author\">@$this->author</a>";
    }
}

Now he second level subclasses only contain creation code: we can move eliminate them if we move this initialization in the construction phase, which is represented by the tests here.

We can substitute $this->author with $this->source:

<?php
class TeaseApartInheritance extends PHPUnit_Framework_TestCase
{
    public function testAFacebookPostIsDisplayedWithTextAndLinkToTheAuthor()
    {
        $post = new FacebookPost("Enjoy!", new FacebookSource("PHP-Cola"));
        $this->assertEquals("<p>Enjoy!"
                          . " -- <a href=\"http://facebook.com/PHP-Cola\">PHP-Cola</a></p>",
                            $post->__toString());
    }

    public function testAFacebookLinkIsDisplayedWithTargetAndLinkToTheAuthor()
    {
        $link = new FacebookLink("Our new ad", "http://youtube.com/...", new FacebookSource("PHP-Cola"));
        $this->assertEquals("<p><a href=\"http://youtube.com/...\">Our new ad</a>"
                          . " -- <a href=\"http://facebook.com/PHP-Cola\">PHP-Cola</a></p>",
                            $link->__toString());
    }

    public function testATwitterLinkIsDisplayedWithTargetAndLinkToTheAuthor()
    {
        $link = new TwitterLink("Our new ad", "http://youtube.com/...", new TwitterSource("giorgiosironi"));
        $this->assertEquals("<p><a href=\"http://youtube.com/...\">Our new ad</a>"
                          . " -- <a href=\"http://twitter.com/giorgiosironi\">@giorgiosironi</a></p>",
                            $link->__toString());
    }
}

abstract class NewsFeedItem
{
    protected $author;
    protected $source;

    public function __toString()
    {
        return "<p>"
             . $this->content()
             . " -- "
             . $this->source->authorLink()
             . "</p>";
    }

    /**
     * @return string
     */
    protected abstract function content();
}

We can now instantiate directly Post and Link by making them concrete instead of abstract. You may want to bundle this step with the previous one as it intervene on the same code. A consequence is that we can throw away the second level subclasses.

class TeaseApartInheritance extends PHPUnit_Framework_TestCase
{
    public function testAFacebookPostIsDisplayedWithTextAndLinkToTheAuthor()
    {
        $post = new Post("Enjoy!", new FacebookSource("PHP-Cola"));
        $this->assertEquals("<p>Enjoy!"
                          . " -- <a href=\"http://facebook.com/PHP-Cola\">PHP-Cola</a></p>",
                            $post->__toString());
    }

    public function testAFacebookLinkIsDisplayedWithTargetAndLinkToTheAuthor()
    {
        $link = new Link("Our new ad", "http://youtube.com/...", new FacebookSource("PHP-Cola"));
        $this->assertEquals("<p><a href=\"http://youtube.com/...\">Our new ad</a>"
                          . " -- <a href=\"http://facebook.com/PHP-Cola\">PHP-Cola</a></p>",
                            $link->__toString());
    }

    public function testATwitterLinkIsDisplayedWithTargetAndLinkToTheAuthor()
    {
        $link = new Link("Our new ad", "http://youtube.com/...", new TwitterSource("giorgiosironi"));
        $this->assertEquals("<p><a href=\"http://youtube.com/...\">Our new ad</a>"
                          . " -- <a href=\"http://twitter.com/giorgiosironi\">@giorgiosironi</a></p>",
                            $link->__toString());
    }
}

The final result can be thought of as a Bridge pattern, or just good factoring:

A further step could be to divide these tests into unit ones, only exercising a NewsFeedItem or a Source object. But that’s a story for another day…