Pull to refresh

Comments 90

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

class BlogController {
    public BlogController (
        TemplateRenderer templateRenderer
    ) {
    }
}
Повторение само по себе не является чем-то плохим.

Оно является чем-то плохим, если надо синхронизировать изменения во всех местах.


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

А в геймдеве композиция выглядит примерно так:
class Container {
    public List<Component> Components;
    public Container(Component[] сomponents);
}

class TemplateRenderer extends Component;

// использование:
var blogController = new Container([templateRenderer]);

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

blogController обычно имеет специфически только для него методы типа showPosts(page = 1), showPost(id), createPost(postData) и т. п. которые и дергают templateRenderer с теми или иными параметрами. Можно, конечно, вместо наследования от базового класса контроллера и явной их имплементации как-то собирать их через лямбды, переданные в конструктор, но, скорее всего, потеряем, как минbмум, автодополнение, контроль типов, удобную навигацию в IDE и т. п.

навскидку:
Component templateRenderer = new TemplateRenderer();
Component postController = new PostController();
postController.templateRenderer = templateRenderer;
Container blogController = new Container([templateRenderer, postController]);

Еще в класс Component можно добавить ссылку на объект-контейнер
и реализовать в нем метод поиска нужного компонента, например по типу.
Тогда вместо строки postController.templateRenderer = templateRenderer;
можно в самом postController сделать поиск templateRenderer среди компонентов связанного объекта-контейнера.

Откуда я такой подход взял:
docs.unity3d.com/ru/current/Manual/UsingComponents.html
martalex.gitbooks.io/gameprogrammingpatterns/content/chapter-5/5.1-component.html
Так, класс Square никогда не должен наследовать класс Rectangle.


а что мешает? раз математически / логически квадрат — частный случай прямоугольника, программирование продиктовано логикой.
Это же классический пример из литературы: у прямоугольника есть методы setWidth и setHeight, которые поидее работают независимо друг от друга. Но когда вы наследуете от него квардрат, вам нужно сделать так, чтобы при изменении ширины/высоты он оставался квдратом, то есть менялась и вторая величина. Кажется, что в этом нет ничего страшного, но нарушается LSP.
Например, вам нужна функция которая увеличивает площадь прямоугольников в 2 раза и подходящее решеним, например, увеличить их ширину в 2 раза:

public static void doubleSquare(List<Rectangle> rectangles) {
    for (Rectangle rectangle : rectangles) {
        rectangle.setWidth(rectangle.getWidth() * 2);
    }
}


Но если в списке прямоугольников будут и квадраты, то поведение становится неправильным.
А если сделать квадрат иммутабельным, то он сможет заменить иммутабельный прямоугольник?
Иммутабельные объекты это уже больше про ФП, тут я привел пример классического объяснения проблемы, возникающей при неправильном наследовании.

Мутабельными могут быть, например, координаты прямоугольник, а ширина и высота иммутабельными. Нельзя говорить что-то вроде "нельзя на следовать Б от А" не объявляя ожидаемого поведения А. Особенно, не добавляя "не нарушая принципа постановки Дисков"

Можно для квадрата переопределить и setWidth, и setHeight.
Например, так, чтобы каждый изменял сразу обе стороны.
Тогда квадрат останется квадратом.
Я кажется так и написал, у квадрата переопределены оба метода и увеличатся в два раза и ширина и высота, а значит площадь увеличится в четыре раза, а не в два. При этом он останется квадратом, но контракт метода будет нарушен.

Сначала нужно зафиксировать контракт. В посте он не зафиксирован.

Это просто означает что ваша функция doubleSquare неверна. И это не проблема квадрата :) Это проблема функции, которая могла бы сначала узнать тип фигуры и применять соответствующий метод.
Тип фигуры функции уже передан — это Rectangle.
Да, и эта функция по хорошему, обязана вызвать соотвествующий виртуальный метод объекта doubleSquare, которые для обычного рестангле и для квадрата будут каждый своим.
Понамешают функциональщины с ООП, потом сами разобраться не могут и кричат, что ООП профнепригодно :)

Функция doubleSquare в том виде как представлена, нарушает принцип инкапсуляции ООП (все изменения объекта только средствами самого объекта). Программист — ССЗБ в этом случае, ООП ни при чем.
А теперь предположим есть отверстие с прямоугольными углами, которое принимает Rectangle, и чтобы наш класс туда поместился необходимо задать ему нужную ширину и высоту и тогда все туда пролезет. Square же попадает под условие сигнатуры, но когда мы попытаемся присвоить ему setWidth(1) и setHeight(2) (что является достаточным условием для метода отверстия), он почему-то туда все равно не пролезет. Налицо нарушение принципа Барбары Лисков. Вызывая один из методов setWidth или setHeight у класса Rectangle, мы ожидаем, что изменим каждую из сторон независимо, но тут внезапно врывается Square, который меняет стороны имплицитно.
мы ожидаем, что изменим каждую из сторон независимо

Почему вы этого ожидаете? Контракт к setWidth/setHeight вам этого не обещал.

Что вы подразумеваете под контрактом? Можем создать класс Weirdtangle, который будет иметь setWidth/setHeight, но имплицитно второе свойство будет устанавливать в ноль. Мы нарушили контракт ожидаемого поведения Rectangle? Сигнатуры в норме, проект собирается, вот только вся логика написанная для работы с Rectangle с этим классом летит как фанера.
Мы нарушили контракт ожидаемого поведения Rectangle?

Без понятия, я не знаю что мне обещает документация к Rectangle. Вы на основании чего-то считаете что после вызова setWidth метод getHeight будет возвращать то же, что и до вызова. Но на основании чего вы так решили?

UFO just landed and posted this here

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

Без понятия, я не знаю что мне обещает документация к Rectangle.

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

Не знаю даже. Может быть вот en.wikipedia.org/wiki/Rectangle
На основании того, что стороны прямоугольника являются не связанными друг с другом величинами, но в совокупности придающими прямоугольнику дополнительные свойства. Например, периметр, выведение которого производится общеизвестным методом.

А может быть и: https://en.wikipedia.org/wiki/Golden_rectangle


Вы зря в обсуждении программной модели пытаетесь сослаться на математические абстракции, которые даже в самой математике имеют множество разных интерпретаций. Классический пример: https://en.wikipedia.org/wiki/0#Mathematics

Потому-что контракт к прямоугольнику это обещал.

Наследники должны расширять поведение родителей, а не замещать.

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

Если обещал.

Ну собственно само название setWidth это подразумевает

Если квадрат является потомком прямоугольника то он обязан проходить все тесты, которые проходит прямоугольник. Гуглите LSP
Если наследник проходит абсолютно все тесты предка, то его поведение не меняется. Но в этом случае, какой смысл создавать новый класс?
Если наследник проходит абсолютно все тесты предка, то его поведение не меняется.

Неверно. Его поведение может меняться без нарушения прохождения тестов. Например могут появиться новые методы или свойства. Могут появиться опциональные аргументы в старых методах.
UFO just landed and posted this here

Зависит от уровня рефлекси. Какой-то user instanceof User обычно возвращает true как для самого User, так и для его наследников. Главное не лезть в имя класса и не проверять отсутствие членов.

Да, забавный артефакт.

Но думаю тут можно простить его нарушение, все-таки instanceof чаще всего возвращает true для подклассов, а если разработчик городит свои костыли, то он ССЗБ.

Да, с setWidth и setHeight всё просто — объект может обеспечить свои инварианты изменя связанные состояния. А вот с методом прямоугольника setDimensions( width , height ) уже сложнее, так как реализовать этот контракт квадрат в принципе не сможет.
На всякий случай — если он начнёт кидать исключение или тупо игнорировать в случае неравных параметров, то это тоже нарушение контракта.

Проблема в том, что при вызове для квадрата setWidth он должен превращаться в прямоугольник. Но в известных мне языках сменить тип объекта на лету нельзя.
Можно пойти функциональным путем и при вызове методов меняющих длину стороны квадрата не менять свойства самого объекта, а создавать новый объект (типа прямоугольник) и возвращать его как результат.
Обычно на практике в таких случаях методы setWidth и setHeight перезаписываются таким образом, что фигура все-таки остается квадратом. Если используется setWidth, то он вызовет setHeight, и наоборот.

Что же касается функции, которая увеличивает площадь в 2 раза, первое, что мне пришло в голову — не ваш пример, а увеличение каждой из сторон в sqrt(2) раза, тогда одна и та же функция подойдет как для прямоугольника, так и для квадрата. С одной стороны увеличить одну из сторон проще, но тогда меняются пропорции прямоугольника, что в некоторых случаях неприемлемо.

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

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

P.S. Мне в JS поначалу сильно не хватало трейтов из php для организации поведений и «мультинаследования» в ООП. Но со временем я научился думать по-другому.

P.P.S. В своих проектах использую оба подхода, но в крупных чаще ООП.
Что же касается функции, которая увеличивает площадь в 2 раза, первое, что мне пришло в голову — не ваш пример, а увеличение каждой из сторон в sqrt(2) раза

Это будет работать, пока компилятор не решит поменять порядок чтения:


  1. прочитать длину, увеличить длину, записать длину,
  2. прочитать ширину, увеличить ширину, записать ширину.

В таком варианте квадрат все равно увеличится в 4 раза.

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

Это же классический пример из литературы: у прямоугольника есть методы setWidth и setHeight, которые поидее работают независимо друг от друга. Но когда вы наследуете от него квардрат, вам нужно сделать так, чтобы при изменении ширины/высоты он оставался квдратом, то есть менялась и вторая величина. Кажется, что в этом нет ничего страшного, но нарушается LSP.

а наследуйте прямоугольник от квадрата да и всё )
Таки квадрат — это четырехугольник. И наследует полностью только его поведение, тогда как поведение прямоугольника и квадрата — различаются между собой.Свойства больше разных у них, чем одинаковых, т.е. и в самом деле этот хрестоматийный пример предполагает, что нет смысла наследоваться именно от прямоугольника(в википедии сказано, что в квадрат — частный случай в том числе и ромба, почем ромб не сделать родителем?)

Если подходить к задаче моделирования геометрии, то там они в принципе иммутабельны.

Логически квадрат это не частный случай прямоугольника а вычисляемое свойство, то есть сущность прямоугольника которая позволяет менять ширину и длину время от времени может становится квадратом при совпадении длины и ширины


class Square {
  isRectangle() {
    return this.getWidth() == this.getHeight()
  }
}
тогда, вероятно, моё утверждение стоит исправить на «квадрат можно наследовать от четырёхугольника».

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

struct Square {                     struct Rectangele 
                                    : Square {
    float a;                          //float a
                                        float b;
    float perimeter() {                 float perimeter() {
         return a * 4                       return (a + b) * 2
    }                                   }
    float area() {                      float area() {
         return a * a                       return a * b;
    }                                   }          
}                                   }

struct Diamond
: Square {
    float angle;
    
    float area() {
        return a * a / 2;
    }
}

struct Parallelogram
: Diamond , Rectangele {
    //...
}                  

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

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

Зависит от набора операций, определённых над прямоугольником. Точнее от его инвариантов и прочих *условий.


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


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

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

Ну вот обычно непонятен точный контекст. Особенно учитывая, что в геометрии как-то не принято изменять фигуры.

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

Статья странная, а книга по принципам ФП — нужна.

Интересно, но не объяснено почему нельзя наследовать Square от Rectangle ("ведут себя по-разному" так себе аргумент). Один комментатор предложил, что у Rectangle меняются обе стороны, а у Square — одна. Так-то оно так, но я бы сказал, что можно реализовать по-другому. Раз Square — частный случай Rectangle (в математике), то я бы написал так:


class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

class Square(Rectangle):
    def __init__(self, size):
        super().__init__(size, size)

А потом какой-нибудь код, не знающий разницы между квадратом и прямоугольником меняет созданному Square width.

Контракт Rectangle подразумевает изменение только одной стороны одной функцией. Если будет логика, которая полагается на независимое изменение сторон, то Square автоматически становится невалидным для этой функции.
Контракт Rectangle подразумевает изменение только одной стороны одной функцией.

Кто сказал?

Название метода setWidth, в котором нет слова Height или Both или какого-нибудь DeleteThisProjectToHell.

У вас контракты описываются исключительно через имя метода?

В числе прочих. Naming convention никто не отменял.

Не расскажете тогда какой у вас в данном случае Naming convention что он позволяет понять что метод setWidth меняет ещё скажем и площадь фигуры?

Площадь это вычисляемое свойство. Следует из контракта самого класса.

Как бы вы отнеслись к коллекции, у которой метод sort вместо сортировки всех данных, удалял бы все содержимое? Не посчитали ли бы вы naming convention такой библиотеки, как минимум, странным? Или если бы коллекция ImmutableItems на самом деле была бы мутабельной? Если вы будете игнорировать naming convention во время составления контракта, то вы потеряете очень важную составляющую.

А контракт класса тоже идёт по naming convention? А что мешает кому-то в контракте класса прописать что высота у данного конкретного класса тоже "вычисляется" и зависит от ширины?

Для этого придётся сделать изменения в базовом классе Rectangle. Это не всегда возможно — либо его кодом владеет другая команда, либо он уже используется в 100500 функциях, и переименование setWidth в setWidthAndPossiblyHeightChanges потребует рефакторинга их всех.

Дело не в этом. Ясное дело что наш пример примитивен и достаточно просто понять как он работает. И как он по идее должен работать мы вроде себе тоже представляем.


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


А если взять не такой банальный пример, а какую-нибудь сложную бизнес-логику из реальных процессов, то там ещё "опаснее" полагаться на интуицию. И только на naming conventions тоже опасно полагаться.

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

И для того чтобы определить противоречат наименования или нет надо иметь на руках этот самый контракт.


И даже в примере с квадратами/прямоугольниками этот "контракт" похоже немного разный у разных людей.

Контракт конвеншина set об ычно подразумевает исключительно что get вернёт то же самое. Ни разу не видел тестов, проверяющих, что результаты других get не изменились после вызова конкретного set.


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

Вы постулируете, что площадь вычисляемое свойство в комментарии Н-го уровня. Не спортивно.

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

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

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

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


Это в общем случае не особо логично, но возможно.

UFO just landed and posted this here
>Не понял, а специфичные для ФП проблемы-то тут где?
Да нигде, что вы. Вы на ссылку-то посмотрите — она ведет, для начала, на обсуждение функции left_pad в NPM. С каких-это пор Javascript (node) и его NPM стали хоть сколько-то репрезентативным примером применения ФП?

>Просто берёшь и заворачиваешься в монаду.
Ну да. Правильнее было бы сказать как-то так: инверсия зависимостей в ФП реализуется и действует иногда совсем не так, как это принято в ООП, что может вводить в заблуждение неопытных разработчиков (я бы сюда включил и автора оригинала). Это было бы правильнее.

Почему совсем не так? Вы не путаете инверсию зависимостей и иньекцией зависимостей?

>Вы не путаете инверсию зависимостей и иньекцией зависимостей?
Не, не путаю. В ООП инверсия зависимостей в том, что зависеть вы должны от интерфейсов, а не от реализаций. В ФП это выглядит иначе. В принципе, мое замечание касается обоих: как инверсии, так и иньекции.

Автор оригинала не удосужился пояснить, что именно он имел в виду, а переводчик усугубил:

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


и почему это оно вдруг усложняется. При этом ссылка в данном абзаце в переводе ведет совсем не туда, как в оригинале:

Оригинал: pasztor.at/blog/clean-code-dependencies
Перевод: www.piter.com/collection/all/product/spring-vse-patterny-proektirovaniya

Ну т.е. автор ссылается на себя, а переводчик — рекламирует свои переводы.

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

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

В целом, хорошо бы вообще обходиться без состояния – хранить в классах изменяемые данные, когда только возможно

вы наверно имели в виду «неизменяемые» данные?

При правильном использовании ООП (да и вообще это одна из главных фишек подхода) объекты в программе всегда находятся в одном из множества корректных состояний. Согласен, энтропию объекта стоит уменьшать. Однако в большинстве случаев иммутабельность для ООП — оверкилл.
  1. Rectangle { setHeigth, setArea; getHeight, getArea, getWidth }
  2. Square: Rectangle
  3. Profit
Я бы зашел с другой стороны…
В природе есть два типа полезных данных — статические и динамические. Пример первых — фотография, пример вторых — видео. Для статических данных (СД) важно их хранение. Например, для фото надо хранить: координаты пикселя, его яркость, его RGB. А для динамических данных (ДД) ничего хранить не надо, единственная задача — это успевать их правильно обрабатывать и выдавать «в никуда», где они могут бесследно и безболезненно исчезать. Пример — видео и смотрящий его человек. Для работы с СД придумали ООП, а для работы с ДД — ФП.
И всё работало более менее нормально, пока не возникла необходимость взаимодействия одновременно с двумя типами данных. И тут пошло поехало…

В чем я вижу проблемы ООП.
а) Идея, что «всё есть объект» (слава Богу, встречается редко). Так как объект — это не просто данные, а нечто, обладающее своим поведением/характером (привет инкапсуляция).
б) Идея, что надо объединять данные и связанные с ним методы в одну сущность. Фактически современный класс — это монолитная программа допроцедурного программирования. А нам достаточно, чтобы объект хранил переменную под присмотром своего «характера». А возможные операции с этими данными надо выносить за пределы класса как такового (привет функциям из ФП).
в) бесконечные попытки выдать наследование и полиморфизм как признаки ООП. Ведь первое — это просто переиспользование кода, а второе — на самом деле ближе к ФП (так как обработка данных).

Проблема ФП.
а) попытка хранить состояние (то есть СД) способами, разработанными для операций с ДД (привет монады).

Мой идеальный мир…
Объекты хранят только состояния, а за пределами объектов — чистое ФП.

Объекты без поведения — это просто структуры данных типа сишных struct

ООП и ФП — уже довольно старые технологии. Т.ч. вместо неких «новых» книг, которые создаются ухудшением старых, предпочёл бы старые.
Хорошие книги написал Бертран Мейер, но они у него объёмные и сложные, т.ч. могут быть трудности с их продажей.
По поводу жалоб на ООП: почти везде эта технология воплощена урезанно и криво, и жалобы больше относятся к языкам программирования, чем к ООП.

Они не столько сложные, сколько оторванные от реалий условно современной разработки

Мочи мочало, начинай сначала. Автор статьи неграмотен: ни что из перечисленного не является неотъемлемой чертой ООП, ни наследование, ни инкапсуляция, ни полиморфизм, ни абстракция. И всё это есть в чисто функциональном Haskell. Так что, объявим Haskell объектно-ориентированным?

А почему никто не сказал, что полиморфизм в статье определен в корне неверно? Автор банально не знает самых основ ООП.

На самом деле очень комично наблюдать со стороны как адепты ООП боба мартина в последние годы открещиваются от наследования, инкапсуляции и полиморфмизма фразами «вы не так понимаете ООП».

Я еще понимаю если бы ООП при этом всеми понималось, трактовалось и училось как завещал Алан Кей - обмен сообщениями между объектами. Но тут именно соль в том, что открыв любую книгу по ООП вы непременно на первой строке прочитаете про вышеперечисленные «устаревшие» принципы.

Сами себе изобрели какие-то неработающие механизмы, сами теперь открещиваются от них. Java Enterprise Hello World какой-то.

Sign up to leave a comment.