Pull to refresh

Книга «Объектно-ориентированный подход. 5-е межд. изд.»

Reading time10 min
Views7.7K
imageОбъектно-ориентированное программирование (ООП) лежит в основе языков C++, Java, C#, Visual Basic .NET, Ruby, Objective-C и даже Swift. Не могут обойтись без объектов веб-технологии, ведь они используют JavaScript, Python и PHP.

Именно поэтому Мэтт Вайсфельд советует выработать объектно-ориентированное мышление и только потом приступать к объектно-ориентированной разработке на конкретном языке программирования.

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

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

Принципы объектно-ориентированной разработки SOLID


1. SRP: принцип единственной ответственности


Принцип единственной ответственности гласит о том, для внесения изменений в класс требуется только одна причина. Каждый класс и модуль программы должны иметь в приоритете одно задание. Поэтому не стоит вносить методы, которые могут вызвать изменения в классе более чем по одной причине. Если описание класса содержит слово «and», то принцип SRP может быть нарушен. Другими словами, каждый модуль или класс должен нести ответственность за одну какую-либо часть функционала программного обеспечения, и такая ответственность должна быть полностью инкапсулирована в класс.

Создание иерархии фигур — это один из классических примеров, иллюстрирующих наследование Этот пример часто встречается в обучении, а я использую его на протяжении этой главы (равно как и всей книги). В этом примере класс Circle наследует атрибуты от класса Shape. Класс Shape предоставляет абстрактный метод calcArea() в качестве контракта для подкласса. Каждый класс, наследующий от Shape, должен иметь собственную реализацию метода calcArea():

abstract class Shape{
     protected String name;
     protected double area;
     public abstract double calcArea();
}

В этом примере класс Circle, наследующий от класса Shape, при необходимости обеспечивает свою реализацию метода calcArea():

class Circle extends Shape{
     private double radius;

     public Circle(double r) {
           radius = r;
     }
     public double calcArea() {
           area = 3.14*(radius*radius) ;
           return (area);
     };
}

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

class CalculateAreas {
     Shape[] shapes;
     double sumTotal=0;
     public CalculateAreas(Shape[] sh) {
           this.shapes = sh;
     }
     public double sumAreas() {
           sumTotal=0;
           for (inti=0; i<shapes.length; i++) {
           sumTotal = sumTotal + shapes[i].calcArea() ;
           }
           return sumTotal ;
     }
     public void output() {
           System.out.printIn("Total of all areas = " + sumTotal);
     }
}

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

Мы можем проверить работоспособность этого кода с помощью соответствующего тестового приложения TestShape:

public class TestShape {
      public static void main(String args[]) {

            System.out.printin("Hello World!");

            Circle circle = new Circle(1);

            Shape[] shapeArray = new Shape[1];
            shapeArray[0] = circle;

            CalculateAreas ca = new CalculateAreas(shapeArray) ;

            ca.sumAreas() ;
            ca.output();
      }
}

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

Основополагающий вопрос (и, собственно, проблема) в том, что если нужно изменить функциональность метода output(), потребуется внести изменения в класс CalculateAreas независимо от того, изменится ли метод подсчета площади фигур. Например, если мы вдруг захотим осуществить вывод данных в HTML-консоль, а не в простой текст, нам потребуется заново компилировать и повторно внедрять код, который складывает площади фигур. Все потому, что ответственности связаны.

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

Чтобы решить данный вопрос, можно поместить два метода в отдельные классы, один для оригинального консольного вывода, другой для вывода в HTML:

class CaiculateAreas {;
     Shape[] shapes;
     double sumTotal=0;

     public CalculateAreas(Shape[] sh) {
           this.shapes = sh;
     }

     public double sumAreas() {
           sumTotal=0;

           for (inti=0; i<shapes.length; i++) {

                sumTotal = sumTotal + shapes[i].calcArea();

           }

                return sumTotal;
           }
}
class OutputAreas {
     double areas=0;
     public OutputAreas (double a) {
           this.areas = a;
     }

           public void console() {
           System.out.printin("Total of all areas = " + areas);
     }
     public void HTML() {
           System.out.printIn("<HTML>") ;
           System.out.printin("Total of all areas = " + areas);
           System.out.printin("</HTML>") ;
     }
}

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

2. OCP: принцип открытости/закрытости


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

Обратим снова внимание на пример с фигурами. В приведенном ниже коде есть класс ShapeCalculator, который берет объект Rectangle, рассчитывает площадь этого объекта и возвращает значения. Это простое приложение, но оно работает только с прямоугольниками.

class Rectangle{
     protected double length;
     protected double width;

     public Rectangle(double 1, double w) {
           length = 1;
           width = w;
     };
}
class CalculateAreas {
     private double area;

     public double calcArea(Rectangle r) {

           area = r.length * r.width;

           return area;
     }
}
public class OpenClosed {
      public static void main(String args[]) {

            System.out.printin("Hello World");

            Rectangle r = new Rectangle(1,2);

            CalculateAreas ca = new CalculateAreas ();

            System.out.printin("Area = "+ ca.calcArea(r));
      }
}

То, что это приложение работает только в случае с прямоугольниками, приводит к ограничению, которое наглядно объясняет принцип открытости/закрытости: если мы хотим добавить класс Circle к классу CalculateArea (изменить то, что он выполняет), нам нужно внести изменения в сам модуль. Очевидно, что это вступает в противоречие с принципом открытости/закрытости, который гласит, что мы не должны вносить изменения в модуль для изменения того, что он выполняет.

Чтобы соответствовать принципу открытости/закрытости, можно вернуться к уже проверенному примеру с фигурами, где создается абстрактный класс Shape а непосредственно фигуры наследуют от класса Shape, у которого есть абстрактный метод getArea().

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

Код ниже обеспечивает реализацию решения для прямоугольников и кругов и позволяет создавать неограниченное количество фигур:

abstract class Shape {
      public abstract double getArea() ;
}
class Rectangle extends Shape
{

      protected double length;
      protected double width;

      public Rectangle(double 1, double w) {
            length = 1;
            width = w;
      };
      public double getArea() {
            return length*width;
      }

}
class Circle extends Shape
{
      protected double radius;

      public Circle(double r) {
            radius = r;
      };
      public double getArea() {
            return radius*radius*3.14;
      }
}
class CalculateAreas {
      private double area;

      public double calcArea(Shape s) {
            area = s.getArea();
            return area;
      }
}

public class OpenClosed {
      public static void main(String args[]) {

            System.out.printiIn("Hello World") ;

            CalculateAreas ca = new CalculateAreas() ;

            Rectangle r = new Rectangle(1,2);

            System.out.printIn("Area = " + ca.calcArea(r));

            Circle c = new Circle(3);

            System.out.printIn("Area = " + ca.calcArea(c));
}
}

Стоит заметить, что при такой реализации в метод CalculateAreas() не должны вноситься изменения при создании нового экземпляра класса Shape.

Можно масштабировать код, не переживая о существовании предыдущего кода. Принцип открытости/закрытости заключается в том, что следует расширять код с помощью подклассов так, чтобы изначальный класс не требовал правок. Однако само понятие «расширение» выступает противоречивым в некоторых обсуждениях, касающихся принципов SOLID. Развернуто говоря, если мы отдаем предпочтение композиции, а не наследованию, как это влияет на принцип открытости/закрытости?

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

3. LSP: принцип подстановки Лисков


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

Рассмотрим некоторый код, который на первый взгляд корректен, тем не менее нарушает принцип подстановки Лисков. В коде, приведенном ниже, присутствует типовой абстрактный класс Shape. Класс Rectangle, в свою очередь, наследует атрибуты от класса Shape и переопределяет его абстрактный метод calcArea(). Класс Square, в свою очередь, наследует от Rectangle.

abstract class Shape{
      protected double area;

      public abstract double calcArea();
}
class Rectangle extends Shape{
      private double length;
      private double width;

      public Rectangle(double 1, double w) {
            length = 1;
            width = w;
      }
      public double calcArea() {
            area = length*width;
            return (area) ;
      };
}
class Square extends Rectangle{
      public Square(double s) {
            super(s, Ss);
      }
}

public class LiskovSubstitution {
      public static void main(String args[]) {

            System.out.printIn("Hello World") ;

            Rectangle r = new Rectangle(1,2);

            System.out.printin("Area = " + r.calcArea());

            Square s = new Square(2) ;

            System.out.printin("Area = " + s.calcArea());
      }
}

Пока что все хорошо: прямоугольник является экземпляром фигуры, поэтому ничего не вызывает беспокойства, поскольку квадрат является экземпляром прямоугольника, — и снова все правильно, правда?

Теперь зададим философский вопрос: а квадрат — это все-таки прямоугольник? Многие ответят утвердительно. Хотя и можно допустить, что квадрат — это частный случай прямоугольника, но его свойства будут отличаться. Прямо­угольник является параллелограммом (противоположные стороны одинаковы), как и квадрат. В то же время квадрат еще и является ромбом (все стороны одинаковы), в то время как прямоугольник — нет. Поэтому различия есть.

Когда дело доходит до объектно-ориентированного проектирования, проблема не в геометрии. Проблема состоит в том, как именно мы создаем прямоугольники и квадраты. Вот конструктор для класса Rectangle:

public Rectangle(double 1, double w) {
      length = 1;
      width = w;
}

Очевидно, конструктор требует два параметра. Однако конструктору для класса Square требуется только один, несмотря даже на то, что родительский класс, Rectangle, требует два.

class Square extends Rectangle{
      public Square(double s) {
      super(s, Ss);
}

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

Как же найти выход из этой ситуации? Попросту говоря, нельзя осуществить подстановку класса Square вместо Rectangle. Таким образом, Square не должен быть дочерним классом Rectangle. Они должны быть отдельными классами.

abstract class Shape {
      protected double area;

      public abstract double calcArea();
}

class Rectangle extends Shape {

      private double length;
      private double width;

      public Rectangle(double 1, double w) {
            length = 1;
            width = w;
      }

      public double calcArea() {
            area = length*width;
            return (area);
      };
}

class Square extends Shape {
      private double side;

      public Square(double s) {
            side = s;
      }
      public double calcArea() {
            area = side*side;
            return (area);
      };
}

public class LiskovSubstitution {
      public static void main(String args[]) {

             System.out.printIn("Hello World") ;

             Rectangle r = new Rectangle(1,2);

             System.out.printIn("Area = " + r.calcArea());

             Square s = new Square(2) ;

             System.out.printIn("Area = " + s.calcArea());
      }
}

4. ISP: принцип разделения интерфейса


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

В этом примере мы создаем единственный интерфейс, который включает в себя несколько поведений для класса Mammal, а именно  eat() и  makeNoise():

interface IMammal {
     public void eat();
     public void makeNoise() ;
}
class Dog implements IMammal {
     public void eat() {
           System.out.printIn("Dog is eating");
     }
     public void makeNoise() {
           System.out.printIn("Dog is making noise");
     }
}
public class MyClass {
      public static void main(String args[]) {

            System.out.printIn("Hello World");

            Dog fido = new Dog();
            fido.eat();
            fido.makeNoise()
      }
}

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

interface IEat {
     public void eat();
}
interface IMakeNoise {
     public void makeNoise() ;
}
class Dog implements IEat, IMakeNoise {
     public void eat() {
           System.out.printIn("Dog is eating");
     }
     public void makeNoise() {
           System.out.printIn("Dog is making noise");
     }
}
public class MyClass {
      public static void main(String args[]) {

            System.out.printIn("Hello World") ;

            Dog fido = new Dog();
            fido.eat();
            fido.makeNoise();
      }
}

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

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

» Более подробно с книгой можно ознакомиться на сайте издательства
» Оглавление
» Отрывок

Для Хаброжителей скидка 25% по купону — ООП

По факту оплаты бумажной версии книги на e-mail высылается электронная книга.
Tags:
Hubs:
+2
Comments3

Articles

Information

Website
piter.com
Registered
Founded
Employees
201–500 employees
Location
Россия