Как составить иерархию классов

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

 Наследование применяется для следующих взаимосвязанных целей:

  1. исключения из программы повторяющихся фрагментов кода;
  2. упрощения модификации программы;
  3. упрощения создания новых программ на основе существующих.

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

Кроме механизма наследования в данном разделе мы рассмотрим такие важные понятия ООП как полиморфизм и инкапсуляцию (см. лекцию 2), которые также принимают участие в формировании иерархии классов.

Наследование

Класс в С# может иметь произвольное количество потомков и только одного предка. При описании класса имя его предка записывается в заголовке класса после двоеточия. Если имя предка не указано, предком считается базовый класс всей иерархии System.Object. Синтаксис наследования:

[атрибуты] [спецификаторы] class имя_класса [: предки]

{ тело_класса}

Обратите внимание на то, что слово «предки» присутствует в описании класса во множественном числе, хотя класс может иметь только одного предка. Это связано с тем, что класс наряду с единственным предком-классом может наследовать интерфейсы (специальный вид классов, не имеющих реализации). Интерфейсы будут рассмотрены чуть позже.

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

Рассмотрим наследование классов на примере геометрических фигур на плоскости. В качестве базового класса создадим класс DemoPoint (точка на плоскости), в качестве производного класса от DemoPoint класс DemoLine (отрезок на плоскости):

   class DemoPoint //базовый класс

   {

      public int x;

      public int y;

      public void Show()

      {

         Console.WriteLine(«({0}, {1})», x, y);

      }

   }

   class DemoLine : DemoPoint //производный класс

   {

      public int xEnd;

      public int yEnd;

      public  void Show()

      {

         Console.WriteLine(«({0}, {1})-({2}, {3})», x, y ,xEnd, yEnd);

      }

   }

   class Program

   {

      static void Main()

      {

         DemoPoint point = new DemoPoint();

         point.x = 0;

         point.y = 0;

         point.Show();

         DemoLine line = new DemoLine();

         line.x = 2;          line.y = 2;

         line.xEnd = 10;  line.yEnd = 10;

         line.Show();

      }

   }

Экземпляр класса DemoLine с одинаковой легкостью использует как собственные поля и методы, так и унаследованные от класса DemoPoint.  При этом, если метод производного класса называется также как и метод базового класса, то вызывается метод производного. Однако компилятором будет сгенерировано предупреждение:

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

Использование защищенного доступа

В нашем примере поля x и у базового класса были открыты для доступа (public). Если убрать public, то поля автоматически станут закрытыми для доступа (private), в том числе и для доступа из производного класса. Решить проблему доступа к закрытым полям базового класса из производного можно двумя способами: используя свойства класса или спецификатор protected. При объявлении какого-то члена класса с помощью спецификатора protected, он становится закрытым для всех классов, кроме производных.

class DemoPoint

   {

         protected int x;

         protected int y;

         public void Show()

         {

            Console.WriteLine(«({0}, {1})»,x, y);

         }

      }

      class DemoLine : DemoPoint

      {

         public int xEnd;

         public int yEnd;

         public new void Show()

         {

            x=2; y=2; //доступ к закрытым полям базового класса

            Console.WriteLine(«({0}, {1})-({2}, {3})», x, y, xEnd, yEnd);

         }

      }

      class Program

      {

         static void Main()

         {

            DemoPoint point = new DemoPoint();

            point.Show();

            DemoLine line = new DemoLine();

            //line.x = 2; line.y = 2; //доступ к полям закрыт

            line.xEnd = 10;                       line.yEnd = 10;

            line.Show();

         }

      }

   }

Обратите внимание на то, что доступ к полям х и y из класса Program невозможен, а из производного класса DemoLine возможен.

Наследование конструкторов

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

В предыдущем примере классы создавались за счет автоматического вызова средствами С# конструктора по умолчанию. Добавим конструктор только в производный класс DemoLine:

class DemoPoint

{

   protected int x;

   protected int y;

   public void Show()

   {

      Console.WriteLine(«({0}, {1})»,x, y);

   }

}

class DemoLine : DemoPoint

{

   public int xEnd;

   public int yEnd;

   new public void Show()

   {

      Console.WriteLine(«({0}, {1})-({2}, {3})», x, y, xEnd, yEnd);

   }

   public DemoLine(int x1, int y1, int x2, int y2) //конструктор производного класса

   {

      x = x1;    y = y1;

      xEnd = x2; yEnd = y2;

   }

}

class Program

{

   static void Main()

   {

      DemoPoint point = new DemoPoint(); //вызывается конструктор по умолчанию

      point.Show();

      DemoLine line = new DemoLine(2, 2, 10, 10); //вызывается собственный конструктор

      line.Show();

   }

}

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

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

1) позволяет вызвать конструктор базового класса:

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

конструктор_производного_класса (список_параметров) : base (список_аргументов)

 { тело конструктора }

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

class DemoPoint

      {

         protected int x;

         protected int y;

         public void Show()

         {

            Console.WriteLine(«({0}, {1})»,x, y);

            }

         public DemoPoint (int x, int y)//конструктор базового класса

         {

            this.x=x;          this.y=y;

         }

      }

class DemoLine : DemoPoint

      {

         public int xEnd;

         public int yEnd;

         new public void Show()

         {

            Console.WriteLine(«({0}, {1})-({2}, {3})», x, y, xEnd, yEnd);

         }

         public DemoLine(int x1, int y1, int x2, int y2):base(x1, y1) //конструктор производного класса

         {

            xEnd = x2; yEnd = y2;

         }

      }

class Program

      {

         static void Main()

         {

            DemoPoint point= new DemoPoint(5, 5);

            point.Show();

            DemoLine line = new DemoLine( 2, 2, 10, 10);

            line.Show();

         }

      }

Задание. Объясните, почему в конструкторе базового класса для инициализации полей используется параметр this, а в конструкторе производного класса нет.

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

class DemoPoint

{

   protected int x;

   protected int y;

   public void Show()

   {

      Console.WriteLine(«({0}, {1})»,x, y);

     }

   public DemoPoint () //конструктор базового класса по умолчанию

   {

      this.x=1;    this.y=1;

   }

   public DemoPoint (int x, int y) //конструктор базового класса с параметрами

   {

      this.x=x;    this.y=y;

   }

}

class DemoLine : DemoPoint

{

   public int xEnd;

   public int yEnd;

   new public void Show()

   {

      Console.WriteLine(«({0}, {1})-({2}, {3})», x, y, xEnd, yEnd);

   }

   public DemoLine() //конструктор производного класса по умолчанию

   {

      xEnd = 100; yEnd = 100;

   }

   public DemoLine(int x2, int y2) //конструктор производного класса с двумя параметрами

   {

      xEnd = x2; yEnd = y2;

   }

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

   public DemoLine(int x1, int y1, int x2, int y2):base(x1, y1)

   {

      xEnd = x2; yEnd = y2;

   }

}

class Program

{

   static void Main()

   {

      DemoPoint point1= new DemoPoint(); //вызов конструктора по умолчанию

      DemoPoint point2= new DemoPoint(5, 5); //вызов конструктора с параметрами

      point1.Show();

      point2.Show();

      DemoLine line1 = new DemoLine();//вызов конструктора по умолчанию

      DemoLine line2 = new DemoLine(4, 4);            //вызов конструктора с двумя параметрами

      //вызов конструктора с четырьмя параметрами

      DemoLine line3 = new DemoLine(2, 2, 10, 10);

      line1.Show();

      line2.Show();

      line3.Show();

   }

}

Задание. Объясните, как при вызове конструкторе производного класса инициируется вызов конструктора базового класса.

2) позволяет получить доступ к члену базового класса, который скрыт «за» членом производного класса.

В этом случаеключевое слово base действует подобно ссылке this, за исключением того, что ссылка base всегда указывает на базовый класс для производного класса, в котором она используется. В этом случае формат ее записи выглядит следующим образом:

base.член_класса

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

class DemoPoint

   {

      protected int x;

      protected int y;

      public void Show()

      {

         Console.Write(«({0}, {1})»,x, y);

      }

      public DemoPoint (int x, int y)//конструктор базового класса

      {

         this.x=x; this.y=y;

      }

   }

   class DemoLine : DemoPoint

   {

      public int xEnd;

      public int yEnd;

      new public void Show()

      {

         base.Show(); //вызов члена базового класса

         Console.WriteLine(«-({0}, {1})», xEnd, yEnd);

      }

      public DemoLine(int x1, int y1, int x2, int y2):base(x1, y1) //конструктор производного класса

      {

         xEnd = x2; yEnd = y2;

      }

   }

   class Program

   {

      static void Main()

      {

         DemoLine line = new DemoLine( 2, 2, 10, 10);

         line.Show();

      }

   }

Несмотря на то, что метод  Show в классе DemoLine скрывает одноименный метод в классе DemoPoint, ссылка base позволяет получить доступ к методу Show в базовом классе. Аналогично с помощью ссылки base можно получить доступ к  одноименным полям базового класса.

Многоуровневая иерархия

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

class DemoPoint

{

   protected int x;

   protected int y;

   public void Show()

   {

         Console.WriteLine(«точка на плоскости: ({0}, {1})»,x, y);

   }

   public DemoPoint (int x, int y)

   {

         this.x=x; this.y=y;

   }

}

class DemoShape : DemoPoint

{

   protected int z;

   new public void Show()

   {

         Console.WriteLine(«точка в пространстве: ({0}, {1}, {2})», x, y, z);

   }

   public DemoShape(int x, int y, int z):base(x, y)

   {

         this.z=z;

   }

}

class DemoLine : DemoPoint

{

   protected int x2;

   protected int y2;

   new public void Show()

   {

         Console.WriteLine(«отрезок на плоскости: ({0}, {1})-({2},{3})»,x,y, x2, y2);

   }

   public DemoLine(int x1, int y1, int x2, int y2):base(x1, y1)

   {

         this.x2 = x2; this.y2 = y2;

   }

}

class DemoTriangle: DemoLine

{

   protected int x3;

   protected int y3;

   new public void Show()

   {

         Console.WriteLine(«треугольник на плоскости: ({0}, {1})-({2},{3})-({4},{5})»,x,y, x2, y2, x3, y3);

   }

   public DemoTriangle(int x1, int y1, int x2, int y2, int x3, int y3):base(x1, y1, x2, y2)              

   {

         this.x3 = x3; this.y3 = y3;

   }

}

class Program

{

   static void Main()

   {

         DemoPoint point = new DemoPoint(1,1);

         point.Show();

         DemoShape pointShape = new DemoShape(1,1,1);

         pointShape.Show();

         DemoLine line = new DemoLine( 2, 2, 10, 10);

         line.Show();

         DemoTriangle triangle = new DemoTriangle (0,0,0,3,4,0);

         triangle.Show();

   }

}

Переменные базового класса и производного класса

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

class DemoPoint

   {

      public int x;

      public int y;

      public void Show()

      {

         Console.WriteLine(«точка на плоскости: ({0}, {1})»,x, y);

      }

      public DemoPoint (int x, int y)

      {

         this.x=x; this.y=y;

      }

   }

class DemoShape : DemoPoint

   {

      public int z;

      new public void Show()

      {

         Console.WriteLine(«точка в пространстве: ({0}, {1}, {2})», x, y, z);

      }

      public DemoShape(int x, int y, int z):base(x, y)

      {

         this.z=z;

      }

}

class Program

   {

      static void Main()

      {

         DemoPoint point1 = new DemoPoint(0,1);

         Console.WriteLine(«({0}, {1})»,point1.x,point1.y);

         DemoShape pointShape = new DemoShape(2,3,4);

         Console.WriteLine(«({0}, {1}, {2})»,pointShape.x, pointShape.y, pointShape.z);

         DemoPoint point2=pointShape; //допустимая операция

    //ошибка — не соответствие типов указателей

    //pointShape=point1;

         Console.WriteLine(«({0}, {1})», point2.x, point2.y);

         //ошибка, т.к. в классе DemoPoint нет поля z

         //Console.WriteLine(«({0}, {1}, {2})», point2.x, point2.y, point2.z);

      }

   }

Ошибка возникнет и при попытке через объект point2 обратиться к методу Show. Например,  point2.Show(). В этом случае компилятор не сможет определить, какой метод Show вызвать –  для базового или для производного класса. Для решения данной проблемы можно воспользоваться таким понятием как полиморфизм, который основывается на механизме виртуальных методов.

Виртуальные методы

Виртуальный метод – это метод, который объявлен в базовом классе с использованием ключевого слова virtual, и затем переопределен в производном классе с помощью ключевого слова override. При этом если реализована многоуровневая иерархия классов, то каждый производный класс может иметь свою собственную версию виртуального метода. Этот факт особенно полезен в случае, когда доступ к объекту производного класса осуществляется через ссылочную переменную базового класса. В этой ситуации С# сам выбирает какую версию виртуального метода нужно вызвать. Этот выбор производится по типу объекта, на которую ссылается данная ссылка. Например:

class DemoPoint //базовый класс

   {

      protected int x;

      protected int y;

      public virtual void Show()           //виртуальный метод

      {

         Console.WriteLine(«точка на плоскости: ({0}, {1})»,x, y);

      }

      public DemoPoint (int x, int y)

      {

         this.x=x; this.y=y;

      }

   }

   class DemoShape : DemoPoint //производный класс

   {

      protected int z;

      public override void Show() //перегрузка виртуального метода

      {

         Console.WriteLine(«точка в пространстве: ({0}, {1}, {2})», x, y, z);

      }

      public DemoShape(int x, int y, int z):base(x, y) //конструктор производного класса

      {

         this.z=z;

      }

   }

   class DemoLine : DemoPoint //производный класс

   {

      protected int x2;

      protected int y2;

      public override void Show()        //перегрузка виртуального метода

      {

         Console.WriteLine(«отрезок на плоскости: ({0}, {1})-({2},{3})»,x,y, x2, y2);

      }

      public DemoLine(int x1, int y1, int x2, int y2):base(x1, y1)

      {

         this.x2 = x2; this.y2 = y2;

      }

   }

   class Program

   {

      static void Main()

      {

         DemoPoint point1 = new DemoPoint(0,1);

         point1.Show();

         DemoShape pointShape = new DemoShape(2,3,4);

         pointShape.Show();

         DemoLine line = new DemoLine(0,0, 10, 10);

         line.Show();

         Console.WriteLine();

         //использование ссылки базового класса на объекты производных классов

         DemoPoint point2=pointShape;

         point2.Show();

         point2=line;

         point2.Show();

      }

   }

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

Абстрактные методы и классы

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

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

Задание. Подумайте, можно ли спецификатор abstract применять в сочетании со спецификатором static. И объясните почему?

Если класс содержит один или несколько абстрактных классов, то его также нужно объявить как абстрактный, используя спецификатор abstract перед class. Поскольку абстрактный класс полностью не реализован, то невозможно создать экземпляр класса с помощью операции new. Например, если класс Demo определен как абстрактный, то попытка создать экземпляр класса Demo повлечет ошибку:

Demo a = new Demo();

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

Demo [] Ob=new Demo[5];

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

Рассмотрим пример использования абстрактных методов и классов.

abstract class Demo //абстрактный класс

   {

      abstract public void Show();//абстрактный метод

      abstract public double Dlina();//абстрактный метод

   }

   class DemoPoint:Demo //производный класс от абстрактного

   {

      protected int x;

      protected int y;

      public DemoPoint (int x, int y)

      {

         this.x=x; this.y=y;

      }

      public override void Show() //переопределение абстрактного метода

      {

         Console.WriteLine(«точка на плоскости: ({0}, {1})»,x, y);

      }

      public override double Dlina()     //переопределение абстрактного метода

      {

         return Math.Sqrt(x*x+y*y);

      }

   }

   class DemoShape : DemoPoint //производный класс

   {

      protected int z;

      public DemoShape(int x, int y, int z):base(x, y)

      {

         this.z=z;

      }

      public override void Show()        //переопределение абстрактного метода

      {

         Console.WriteLine(«точка в пространстве: ({0}, {1}, {2})», x, y, z);

      }

      public override double Dlina()     //переопределение абстрактного метода

      {

         return Math.Sqrt(x*x+y*y+z*z);

      }

   }

   class DemoLine : DemoPoint //производный класс

   {

      protected int x2;

      protected int y2;

      public DemoLine(int x1, int y1, int x2, int y2):base(x1, y1)

      {

         this.x2 = x2; this.y2 = y2;

      }

      public override void Show()        //переопределение абстрактного метода

      {

         Console.WriteLine(«отрезок на плоскости: ({0}, {1})-({2},{3})»,x,y, x2, y2);

      }

      public override double Dlina()     //переопределение абстрактного метода

      {

         return Math.Sqrt((x-x2)*(x-x2)+(y-y2)*(y-y2));

      }

   }

   class Program

   {

      static void Main()

      {

         Demo [] Ob=new Demo[5]; //массив ссылок

         //заполнения массива ссылками на объекты производных классов

         Ob[0]=new DemoPoint(1,1);

         Ob[1]=new DemoShape(1,1,1);

         Ob[2]=new DemoLine(0,3,4,0);

         Ob[3]=new DemoLine(2,1,2,10);

         Ob[4]=new DemoPoint(0,100);

         foreach (Demo a in Ob) //просмотр массива

         {

               a.Show();

               Console.WriteLine(«Dlina: {0:f2}n», a.Dlina());

         }

      }

   }

Запрет наследования

В С# есть ключевое слово sealed, позволяющее описать класс, от которого запрещено наследование. Например:

sealed class Demo {   … }

class newDemo: Demo {   …  }  // ошибка

Задание. Подумайте:

  1. для чего может создаваться класс, от которого нельзя наследовать?
  2. можно ли использовать сочетание спецификаторов sealed и abstract при описании класса, и почему?


Как работать с иерархической структурой классов

Время на прочтение
7 мин

Количество просмотров 4.2K

Задача классификации — одна из самых известных в машинном обучении. Очень многие проблемы, решаемые с помощью ML, так или иначе сводятся к классификации — распознавание изображений, например. И все выглядит просто и понятно, когда нам нужно определить объект в один из нескольких классов. А что если у нас не плоская структура из нескольких классов, а сложная разветвленная иерархия на 683 категории? Именно о таком случае мы сегодня и поговорим. Под катом — рассказ о том, зачем в задачах классификации нужны сложные иерархии и как с ними жить.

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

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

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

  • Как с этим всем обращаться? 

  • Какие классы предсказывать? 

  • Сколько моделей тренировать для решения задачи? 

  • Как работать с данными? 

  • Как вносить изменения в иерархию классов и как реагировать на эти изменения с точки зрения модели?

Все эти проблемы мы разберем на примере задачи классификации, которую мы решаем в Контуре.

Постановка задачи

Мы работаем с чеками. В каждом чеке может встретиться много разных товаров, которые можно сгруппировать множеством разных способов. На данный момент нам интересно группировать эти товары с помощью дерева категорий, которое мы будем называть KPC (Khajiit Product Classification). Это здоровенное дерево, состоящее из 683 категорий.

Для этих категорий у нас есть Golden Set, наш размеченный набор данных (штрихкод — категория KPC) для обучения. В датасете почти три миллиона записей и мы постоянно работаем над его дополнением и улучшением разметки.

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

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

Разметка данных

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

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

  • Активный.

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

  • Архивный — категория, которую решено было удалить, но товары из категории еще не переразметили.

  • Удаленный.

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

Также стоит упомянуть две отдельные группы категорий:

  • «свалка» (например «Одежда (свалка)») — группа для логического удаления некоторых товаров, которые невозможно категоризировать. Например, у нас есть товар «Полное тестирование Тест 2 10шт», у которого из какого-то источника данных стоит категория Одежда. Наш аналитик данных понимает, что по факту категории у такого товара нет, поэтому такой товар отправляется в свалку, которая при обучении не рассматривается.

  • «другое/другие» (например «Молочные товары, сыры и яйцо (другое)») — группа для товаров, которые относятся к родительской категории, но не относятся (возможно, пока) ни к одной из дочерних.

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

Добавление категории

Добавить категорию мы можем в любое время, но для того, чтобы товары начали в неё попадать, необходимо:

  • Добавить категорию в KPC.

  • Переразметить обучающую выборку в соответствии с новой категорией (если товары новой категории раньше относились к другой категории — проверить, что теперь они относятся к правильной).

  • Переобучить модель, чтобы она научилась классифицировать товары в новую категорию.

Удаление категории

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

Для удаления категории необходимо:

  • Перевести категорию в статус Архивная.

  • Решить, что мы делаем с товарами, которые относились к удаляемой и дочерним категориям.

  • Заменить удаляемую категорию у товаров в Golden Set.

  • Указать дочерним категориям новую родительскую категорию или её отсутствие (если дочерняя категория должна стать категорией верхнего уровня).

  • Переобучить модель, чтобы она перестала классифицировать товары в удаляемую категорию (и начала классифицировать эти товары в новые категории).

  • Перевести категорию в статус Удаленная.

Разбиение категории

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

  • Обновить категории в Golden Set, чтобы отнести товары к новым категориям.

  • Переобучить модель, чтобы она научилась классифицировать товары в новые категории.

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

Обучение модели

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

Такие коллизии плохо влияют на обучение — товар может одновременно оказаться в обеих категориях и это может смутить нашу модель. Чтобы избежать таких случаев, перед обучением добавлен этап разрешения конфликтов дерева категорий (KPC collisions resolving).

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

Алгоритм разрешения конфликтов

Для начала разберемся с тем, что такое конфликт или коллизия. Коллизией мы считаем ситуацию, когда пара категорий ребенок/родитель одновременно представлена в KPC. То есть часть товаров размечена родительской категорией, часть — дочерней. Как уже упоминалось выше, такие ситуации плохо влияют на обучение.

Для разрешения этих ситуаций мы будем маппить категории (то есть переносить все товары из одной категории в другую), а после обновлять наше дерево категорий (оставлять в рассмотрении только те категории, которые представлены в Golden set), проверяя, остались ли коллизии.

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

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

Future/Active на схеме — это статусы категорий Запланированная/Активная, а present/NOT present in GS — представлена ли категория в Golden set.

Еще один вопрос, который важно разобрать — что мы хотим делать с Запланированными категориями? И что мы хотим делать с их детьми?

Есть несколько вариантов. Мы можем:

  • Использовать эти категории в классификации.

  • Не классифицировать и выбросить категории из GS.

  • Переразмечать эти категории в категорию-родителя.

  • Переразмечать эти товары в категорию «другое/другие» (например «Молочные продукты, сыры и яйцо (другое)»)

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

  1. Убрать удаленные, редко встречающиеся (меньше 10 товаров в golden set) и те категории, у которых в названии есть «свалка».

  2. Если все дети категории в статусе «Запланированный», то  дочерние категории маппятся в родителя. Это происходит итеративно, так как после первого маппинга может возникнуть аналогичная ситуация на другом уровне дерева.

  3. Смаппить запланированные категории в sibling-категорию с «другое/другие» в названии, если такая есть.

  4. Удалить из Golden Set категории, у которых есть категории-потомки с товарами в Golden Set. Здесь происходит то самое разрешение конфликтов.

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

Валидация модели

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

В первую очередь мы сравниваем базовые метрики — accuracy (по всем классам) и accuracy/coverage. Необходимо следить за тем, чтобы баланс покрытия и точности не нарушался, а также за возможностью подобрать threshold для новой модели, при котором этот баланс соответствует нашим требованиям.

Во вторую очередь будем смотреть на метрики отдельно по каждому классу. Сначала на те, с которыми модель непосредственно знакома. Затем на родительские классы, путем агрегации (взвешенное среднее).

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

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

'errors' - sum of errors of confusing two labels,
'label_1_confuse' - count(true=label_1, pred=label_2) / 'errors',
'label_2_confuse' - count(true=label_2, pred=label_1) / 'errors',
'fraction_of_error_to_label_1' - count(true=label_1, pred=label_2) / total_label_1,
'fraction_of_error_to_label_2' - count(true=label_2, pred=label_1) / total_label_2,
'fraction_of_all_errors' - 'errors' / total_errors,
'fraction_of_all_errors_cumulative'

Выводы

  1. Для удобной итеративной работы с деревом категорий полезно ввести статусы категорий. Такие статусы позволят обрабатывать категории в разном состоянии разными способами.

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

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

Аннотация: Лекция рассматривает иерархию классов: наследование, его виды, практические примеры.

14.1 Иерархия

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

Наследование применяется для следующих взаимосвязанных целей:

  1. исключения из программы повторяющихся фрагментов кода;
  2. упрощения модификации программы;
  3. упрощения создания новых программ на основе существующих.

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

Кроме механизма наследования в данном разделе мы рассмотрим такие важные понятия ООП как полиморфизм и инкапсуляцию (см.
«Технология объектно-ориентированного программирования»
), которые также принимают участие в формировании иерархии классов.

Наследование

Класс в С# может иметь произвольное количество потомков и только одного предка. При описании класса имя его предка записывается в заголовке класса после двоеточия. Если имя предка не указано, предком считается базовый класс всей иерархии System.Object. Синтаксис наследования:

[атрибуты] [спецификаторы] class имя_класса [: предки]
{ тело_класса}

Обратите внимание на то, что слово «предки» присутствует в описании класса во множественном числе, хотя класс может иметь только одного предка. Это связано с тем, что класс наряду с единственным предком-классом может наследовать интерфейсы (специальный вид классов, не имеющих реализации). Интерфейсы будут рассмотрены чуть позже.

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

Рассмотрим наследование классов на примере геометрических фигур на плоскости. В качестве базового класса создадим класс DemoPoint (точка на плоскости), в качестве производного класса от DemoPoint класс DemoLine (отрезок на плоскости):

class DemoPoint //базовый класс
  {
    public int x;
    public int y;
    public void Show()
    {
      Console.WriteLine("({0}, {1})", x, y);
    }
  }

  class DemoLine : DemoPoint //производный класс
  {
    public int xEnd;
    public int yEnd;
    public  void Show()
    {
      Console.WriteLine("({0}, {1})-({2}, {3})", x, y ,xEnd, yEnd);
    }
  }

  class Program
  {
    static void Main()
    {
      DemoPoint point = new DemoPoint();
      point.x = 0;
      point.y = 0;
      point.Show();
      DemoLine line = new DemoLine();
      line.x = 2;  line.y = 2;
      line.xEnd = 10;  line.yEnd = 10;
      line.Show();
    }
  }

Экземпляр класса DemoLine с одинаковой легкостью использует как собственные поля и методы, так и унаследованные от класса DemoPoint. При этом, если метод производного класса называется также как и метод базового класса, то вызывается метод производного. Однако компилятором будет сгенерировано предупреждение:

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

Использование защищенного доступа

В нашем примере поля x и у базового класса были открыты для доступа ( public ). Если убрать public, то поля автоматически станут закрытыми для доступа ( private ), в том числе и для доступа из производного класса. Решить проблему доступа к закрытым полям базового класса из производного можно двумя способами: используя свойства класса или спецификатор protected. При объявлении какого-то члена класса с помощью спецификатора protected, он становится закрытым для всех классов, кроме производных.

class DemoPoint
  {
      protected int x;
      protected int y;
      public void Show()
      {
        Console.WriteLine("({0}, {1})",x, y);
      }
    }

    class DemoLine : DemoPoint
    {
      public int xEnd;
      public int yEnd;
      public new void Show()
      {
        x=2; y=2; //доступ к закрытым полям базового класса
        Console.WriteLine("({0}, {1})-({2}, {3})", x, y, xEnd, yEnd);
      }
    }
   
    class Program
    {
      static void Main()
      {
        DemoPoint point = new DemoPoint();
        point.Show();
        DemoLine line = new DemoLine();
        //line.x = 2; line.y = 2; //доступ к полям закрыт
        line.xEnd = 10;    line.yEnd = 10;
        line.Show();
      }
    }
  }

Обратите внимание на то, что доступ к полям х и y из класса Program невозможен, а из производного класса DemoLine возможен.

Наследование конструкторов

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

В предыдущем примере классы создавались за счет автоматического вызова средствами С# конструктора по умолчанию. Добавим конструктор только в производный класс DemoLine:

class DemoPoint
{
  protected int x;
  protected int y;
  public void Show()
  {
    Console.WriteLine("({0}, {1})",x, y);
  }
}

class DemoLine : DemoPoint
{
  public int xEnd;
  public int yEnd;
  new public void Show()
  {
    Console.WriteLine("({0}, {1})-({2}, {3})", x, y, xEnd, yEnd);
  }

  public DemoLine(int x1, int y1, int x2, int y2) //конструктор производного класса
  {
    x = x1;    y = y1;
    xEnd = x2; yEnd = y2;
  }
}

class Program
{
  static void Main()
  {
    DemoPoint point = new DemoPoint(); //вызывается конструктор по умолчанию 
    point.Show();
    DemoLine line = new DemoLine(2, 2, 10, 10); //вызывается собственный конструктор 
    line.Show();
  }
}

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

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

  1. позволяет вызвать конструктор базового класса:

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

    конструктор_производного_класса (список_параметров) : base (список_аргументов)
    { тело конструктора }
    
    где с помощью элемента списка аргументов передаются параметры конструктору базового класса. Например: 
    
    class DemoPoint
        {
          protected int x;
          protected int y;
          public void Show()
          {
            Console.WriteLine("({0}, {1})",x, y);
    }
          public DemoPoint (int x, int y)//конструктор базового класса
          {
            this.x=x;  this.y=y;
          }
        }
    
    class DemoLine : DemoPoint
        {
          public int xEnd;
          public int yEnd;
          new public void Show()
          {
            Console.WriteLine("({0}, {1})-({2}, {3})", x, y, xEnd, yEnd);
          }
    
          public DemoLine(int x1, int y1, int x2, int y2):base(x1, y1) //конструктор производного класса
          {
            xEnd = x2; yEnd = y2;
          }
        }
    
    class Program
        {
          static void Main()
          {
            DemoPoint point= new DemoPoint(5, 5);
            point.Show();
            DemoLine line = new DemoLine( 2, 2, 10, 10);
            line.Show();
          }
        }

    Задание. Объясните, почему в конструкторе базового класса для инициализации полей используется параметр this, а в конструкторе производного класса нет.

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

    class DemoPoint
    {
      protected int x;
      protected int y;
      public void Show()
      {
        Console.WriteLine("({0}, {1})",x, y);
    }
      public DemoPoint () //конструктор базового класса по умолчанию
      {
        this.x=1;  this.y=1;
      }
      public DemoPoint (int x, int y) //конструктор базового класса с параметрами
      {
        this.x=x;  this.y=y;
      }
    }
    
    class DemoLine : DemoPoint
    {
      public int xEnd;
      public int yEnd;
      new public void Show()
      {
        Console.WriteLine("({0}, {1})-({2}, {3})", x, y, xEnd, yEnd);
      }
      public DemoLine() //конструктор производного класса по умолчанию
      {
        xEnd = 100; yEnd = 100;
      }
      public DemoLine(int x2, int y2) //конструктор производного класса с двумя параметрами
      {
        xEnd = x2; yEnd = y2;
      }  
      //конструктор производного класса с четырьмя параметрами
      public DemoLine(int x1, int y1, int x2, int y2):base(x1, y1)
      {
        xEnd = x2; yEnd = y2;
      }
    }
    
    class Program
    {
      static void Main()
      {
        DemoPoint point1= new DemoPoint(); //вызов конструктора по умолчанию
        DemoPoint point2= new DemoPoint(5, 5); //вызов конструктора с параметрами
        point1.Show();
        point2.Show();
        DemoLine line1 = new DemoLine();//вызов конструктора по умолчанию
        DemoLine line2 = new DemoLine(4, 4);   //вызов конструктора с двумя параметрами
        //вызов конструктора с четырьмя параметрами
        DemoLine line3 = new DemoLine(2, 2, 10, 10);
        line1.Show();
        line2.Show();
        line3.Show();
      }
    }

    Задание. Объясните, как при вызове конструкторе производного класса инициируется вызов конструктора базового класса.

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

    В этом случае ключевое слово base действует подобно ссылке this, за исключением того, что ссылка base всегда указывает на базовый класс для производного класса, в котором она используется. В этом случае формат ее записи выглядит следующим образом:

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

    class DemoPoint
      {
        protected int x;
        protected int y;
        public void Show()
        {
          Console.Write("({0}, {1})",x, y);
        }
        public DemoPoint (int x, int y)//конструктор базового класса
        {
          this.x=x;  this.y=y;
        }
      }
    
      class DemoLine : DemoPoint
      {
        public int xEnd;
        public int yEnd;
        new public void Show()
        {
          base.Show(); //вызов члена базового класса
          Console.WriteLine("-({0}, {1})", xEnd, yEnd);
        }
    
        public DemoLine(int x1, int y1, int x2, int y2):base(x1, y1) //конструктор производного класса
        {
          xEnd = x2; yEnd = y2;
        }
      }
    
      class Program
      {
        static void Main()
        {
          DemoLine line = new DemoLine( 2, 2, 10, 10);
          line.Show();
        }
      }

    Несмотря на то, что метод Show в классе DemoLine скрывает одноименный метод в классе DemoPoint, ссылка base позволяет получить доступ к методу Show в базовом классе. Аналогично с помощью ссылки base можно получить доступ к одноименным полям базового класса.

Наследование

  • Наследование
    • Введение
    • Создание иерархии классов
      • Примечание
    • Отношения между классами
      • Has-A Relationship
        • Отличия композиции от агрегации
    • Минусы и плюсы подходов
      • Отношение is a
        • Ситуация с Бонни: MyHashMap
      • Отношение has a
    • Множественное наследование
    • Заключение
    • Полезные ссылки

Введение

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

Сперва давайте постараемся ответить на вопрос: Зачем вообще придумали Наследование?

Для ответа на него давайте объявим некоторый класс.

Тут нам на помощь снова придет извечный бродяга примеров: класс Person.

class Person {
  private int age;
  private String name;

  // some remaining code
}

Пока все прекрасно и удобно.

Но представим, что теперь понадобилось создать новый класс Employee — работника.

Employee имеет те же черты, что и Person, но вдобавок к этому он еще устроен на работу, получает зарплату и т.д.
Т.е это тот же Person, но с еще одним дополнительным полем — работа.

В мире, где механизм наследования отсутствует, мы бы объявили этот класс следующим образом:

class Employee {
  private int age;
  private String name;
  private String work;

  // some remaining code
}

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

Вдобавок к этому есть и еще один неприятный момент!

Для нас очевидно, что эти классы связаны логически, т.е Employee — это какой-то Person, устроенный на работу, но для Java эта связь совсем не очевидна, с точки зрения языка это просто два разных, никак не связанных и не имеющих ничего общего класса.

Но мы-то знаем, что это не так!

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

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

Создание иерархии классов

Для того, чтобы воспользоваться механизмом наследования, т.е унаследовать один класс от другого, в Java существует ключевое слово extends.

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

Перепишем теперь наш код с использованием наследования.

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

Про модификаторы доступа нужно почитать тут.

Про this и super.

class Person {
  protected int age;
  protected String name;

  public Person(int age, String name) {
    this.age = age;
    this.name = name;
  }

  // some remaining code
}

class Employee extends Person {
    private String address;

    public Employee(int age, String name, String address) {
        super(age, name);
        this.address = address
    }
}

Теперь, мало того, что код получился более компактным, так как мы убрали дублирование, так еще и выстроилась иерархия классов.
Классы Person и Employee уже больше не являются не связанными, как это было в первом примере.

Для демонстрации этого мы просто создадим метод, который ждет на вход экземпляры класса Person.
Допустим, у нас реализована некая телефонная книга:

class PhoneBook {
    public static void find(Person p) {
        System.out.println(p.name);
    }

    // some code
}

Запустите следующий код c классами, которы мы только что написали:

public static void main(String[] args) {
    Person p = new Person(27, "Aleksandr");
    Employee e = new Employee(27, "Maksim", "Sberbank");

    PhoneBook.find(p);
    PhoneBook.find(e);
}

Благодаря тому, что Employee является наследником Person код скомпилируется, а при его выполнении вы увидите в консоли:

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

Если же вы попробуете повторить тот же трюк, но с классами не использовавшими наследование, как в начальном варианте, код даже не скомпилируется, ведь телефонная книга ждет класс Person.
Но Person и Employee в первом варианте — это два абсолютно разных класса, которые никак не связаны друг с другом.

Примечание

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

Как мы уже обсуждали во введении в ООП, класс — это совокупность поведения и состояния.

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

Помните, что наследование — это приобретение и состояния, и поведения класса-родителя.

Наследование — это приобретение и состояния, и поведения класса-родителя.

Отношения между классами

Рассмотрим следующий вопрос. У нас есть классы: Оружие и Солдат. Солдат должен уметь стрелять. Допустимо ли отнаследоваться классом Солдат от Оружия, тем самым расширив наш класс и получить необходимые стрелковые навыки?

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

В связи с этим возникает вопрос: когда можно использовать наследование, а когда не надо?

Снова рассмотрим два класса, что были приведены выше: Оружие и Солдат.

Для ответа на вопрос: уместно ли здесь применить наследование спросите себя: какое отношение между классами? Можно ли сказать, что потенциальный потомок является тем же, что и родительский класс? Т.е отвечает ли класс Солдат отношению is a по отношению к Оружию?

Если ответ ‘да’, является, то наследование вполне применимо в этом случае. Если же на вопрос «является ли класс тем же, что и родитель» ответ отрицательный — то в таком случае использовать наследование не рекомендуется.

В случае отрицательного ответа на предыдущий вопрос следуют снова спросить себя: отвечает ли класс Солдат отношению has a по отношению к Оружию? Т.е является ли один объект является составной частью другого?

Скорее всего ответ будет да и тогда правильнее использовать композицию.

В нашем случае ответ на вопрос: класс Солдат связан взаимоотношением has a по отношению к Оружию. Это значит, что Оружие будет полем класса Солдат. При этом инициализация поля может быть объявлена по разному: через конструктор или через setter-метод.

В таком случае, поведение, которое мы хотим добавить Солдату, будет объявлено следующим образом:

class Solder {
  private Weapon weapon;

  public setWeapon(Weapon weapon) {
    this.weapon = weapon;
  }

  public void fire() {
    wepon.fire();
  }
}

Если же ответ на оба вопроса про has a и is a будет нет, то вы скорее всего что-то делаете не так и строите неправильную абстракцию.


Вопрос:

Для проверки возьмем еще два класса: Figure и Rectangle.
Определите взаимотношение классов и что будет логичнее использовать: наследование или композицию.

Ответ:

Является ли прямоуголник некоей абстрактной фигурой? Скорее всего да. Отношение взаимодействия is a.
Значит, логично сделать так, что Rectangle является наследником Figure.

class Rectangle extends Figure {
  // some code
}

Теперь чуть подробнее поговорим про has a и композицию.

Has-A Relationship

Существует несколько видов взаимодействия объектов, объединенных под общим понятием «Has-A Relationship» или «Part Of Relationship».
Это отношение означает, что один объект является составной частью другого объекта.

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

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

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

Отличия композиции от агрегации

  • Композиция

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

    Это как сердце и человек, сердце — это часть человека, но отдельно от него оно не может
    существовать, не может или его существоание не имеет смысла.

  • Агрегация

    Агрегация — это такой случай, когда составные части класса могут существовать без
    существования класса, в который они входят.

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

Композиция — это частный случай агрегации.

Часто для простоты говорят просто композиция, не делая различий с агрегацией.

Давайте теперь разберем минусы и плюсы каждого из подходов: композиции/агрегации и наследования?

Минусы и плюсы подходов

Отношение is a

Плюсы:

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

Минусы:

  • Дочерний класс зависит от изменений в родительском классе, изменив что-то в родительском классе, мы автоматически получаем эти изменения в дочернем.

    Пусть у нас есть своя реализация MyHashMap, так как мы хотим переиспользовать часть того, что есть в HashMap, мы отнаследовались от нее и переопределили метод add(...).
    Все отлично, но теперь, если разработчики HashMap добавят метод addAll(..), использующий add для добавляения элементов, у нас будет в этом месте дыра, ведь у нас-то своя реализация add, а унаследованный метод addAll будет добавлять элементы ‘по старому’.

  • Ошибка в неверной иерархии наследования ведет к большим проблемам в использовании кода в будущем.

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

  • Нарушение инкапсуляции.

    В качестве примера посмотрите на следующий код:

    public class Artist {
      protected String name;
      protected List<String> albums;
    
      public Artist(String name) {
          this.name = name;
      }
    }
    
    class Second extends Artist {
      public void breakEncapsulation() {
          System.out.println(super.name);
      }
    }

    Здесь мы через super обратились к полю родительского класса.

  • Тянем все проблемы и ошибки наследованного кода.

  • Тяжело выстраивать правильные абстракции.

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

Для иллюстрации этого приведем следующий пример.

Ситуация с Бонни: MyHashMap

Почему при композиции мы избегаем ситуации описанной в примере с MyHashMap?

Ответ прост: при композиции мы просто создадим HashMap полем класса и сами пропишем интерфейс нашего класса, вызывая лишь те методы, которые нам нужны.
При этом ситуаций, которые описаны выше, просто не возникнет — мы определим метод add, как нам надо и даже если разработчики добавят в HashMap какой-то
свой еще один addAll метод — нас это никак не затронет, так как наш класс умеет только то, что мы прописали — и не более.

Т.е при композиции мы максимально контролируем и знаем поведение нашего класса: ведь мы сами его и написали.
Никакие «новые» методы не могут попасть в интерфейс нашего класса — пока мы сами их явно не вызовем и не напишем на них свои обертки.

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

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

Отношение has a

Плюсы:

  • Ситуации на подобие той, что описана выше с MyHashMap исключены.
  • Возможность скрыть проблемы класса-родителя, создав обертку, в которой скроем недостатки API класс-родителя.
  • Легко применять и строить абстракции.

Минусы:

  • Иногда действительно неудобно работать с наследованием и иерархией классов.
  • Если объектов-владельцев достаточно много, то создание и уничтожение вместо одного объекта двух или более может пагубно сказаться на производтельнсоти.

Наследование — это именно расширение какого-то функционала, в то время как композиция — это включение(внедрение) функционала.

Существует даже правило:

Предпочитайте композицию наследованию.

Множественное наследование

Снова начнем с вопроса: может ли у класса быть более одного предка?

java.lang.Object мы не берем в виду, так как он является родительским классом для всех.

В языках программирования типа C++, Python и т.д это возможно, такой механизм называется множественным наследованием.
В Java так сделать нельзя.

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

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

Применительно к программированию такие просчеты могут быть фатальными и разрушить всю архитектуру приложения.
Частично из-за возможности подобных ошибок в Java просто решили не разрешать множественное наследование.

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

Заключение

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

Однако, насколько этот инструмент мощный, настолько же и опасный.

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

Чаще задавайтесь вопросом: каким отношением связаны ваши классы?

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

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

Также советую ознакомиться с принципами SOLID

Полезные ссылки

  1. Java. Эффективное программирование | Блох Джошуа #Главы 16-17
  2. Composition vs. Inheritance: How to Choose?

Построение иерархии классов

При
использовании наследования в C++ для
порождения одного класса из другого
возможны ситуации, когда вы порождаете
свой класс из класса, который уже, в свою
очередь, является производным от
некоторого базового класса. Например,
предположим, вам необходимо использовать
класс сотputer базовый для порождения
класса workstation, как показано ниже:

class
work_station : public computer

public: 

   work_station
(char *operating_system, char *name, int hard_disk, float
floppy, char *screen, long colors, int x_res, int
      y_res, int processor, int
speed, int RAM); 

   void
show_work_station(void); 

private: 

   char
operating_system[64]; 

};

Конструктор
класса workstation просто вызывает конструктор
класса computer, который в свою очередь
вызывает конструкторы классов
сотрuter_screen и mother_board:

work_station::work_station(
char *operating_system, char *name, int hard_disk, float floppy, char
*screen, long colors, int    x_res, int y_res, int
processor, int speed, int RAM) : computer (name, hard_disk,
floppy, screen, colors, x_res, y_res,    processor,
speed, RAM)

   strcpy(work_station::operating_system,
operating_system); 

}

В
данном случае класс computer выступает в
роли базового класса. Однако вы знаете,
что класс computer был порожден из классов
computer_screen и mother_board. В результате класс
work_station наследует характеристики всех
трех классов. На рис. 27 показано, что
порождение классов приводит к иерархии
классов.

Рис.
27. Построение иерархии классов.

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

Что вам необходимо знать

Множественное
наследование представляет собой
возможность порождать класс из нескольких
базовых классов. При использовании
множественного наследования производный
класс получает характеристики (элементы)
существующих базовых классов. Поддержка
множественного наследования в C++
предоставляет вашим программам огромные
возможности объектно-ориентированного
программирования. Из урока 28 вы узнаете,
как обеспечить доступ к частным элементам
класса со стороны других классов или
функций других классов, которые вы
указываете как друзей. Используя таких
друзей, вы можете предоставить определенным
функциям прямой доступ к элементам
класса, одновременно обеспечивая их
защиту от остальной части программы.
Прежде чем перейти к уроку 28, убедитесь,
что вы изучили следующее:

    1. Множественное
      наследование является способностью
      порожденного класса наследовать
      характеристики нескольких базовых
      классов.

    2. Для
      порождения класса из нескольких базовых
      после имени нового класса и двоеточия
      вы указываете имена базовых классов,
      разделяя их запятыми, например class
      cabbit: public cat, public rabbit.

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

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

УРОК
28. ЧАСТНЫЕ ЭЛЕМЕНТЫ И ДРУЗЬЯ

Как
вы уже знаете, ваши программы могут
обращаться к частным (private) элементам
класса только с помощью функций-элементов
этого же класса. Используя частные
элементы класса вместо общих во всех
ситуациях, где это только возможно, вы
уменьшаете возможность программы
испортить значения элементов класса,
так как программа может обращаться
к таким элементам только через интерфейсные
функции (которые управляют доступом к
частным элементам). Однако в зависимости
от использования объектов вашей
программы, иногда вы можете существенно
увеличить производительность позволяя
одному классу напрямую обращаться к
частным элементам другого. В этом случае
уменьшаются издержки (требуемое время
выполнения) на вызов интерфейсных
функций. В подобных ситуациях C++ позволяет
определить класс в качестве друга
(friend} другого класса и разрешает
классу-другу доступ к частным элементам
этого другого класса. В этом уроке
объясняется, как ваши программы могут
указать, что два класса являются друзьями.
К концу данного урока вы освоите следующие
основные концепции:

  • Используя
    ключевое слово friend, класс может сообщить
    C++, кто является его другом, т. е. другими
    словами, что другие классы могут
    обращаться напрямую к его частным
    элементам.

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

  • C++
    позволяет ограничить дружественный
    доступ определенным набором функций.

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

Соседние файлы в предмете [НЕСОРТИРОВАННОЕ]

  • #
  • #
  • #
  • #
  • #
  • #
  • #
  • #
  • #
  • #
  • #

Понравилась статья? Поделить с друзьями:
  • Как исправить складки на брюках спереди от гульфика
  • Как найти ответы на вопросы по биологии
  • Как найти линию любви
  • Как найти лебединое озеро
  • Ошибка вне диапазона как исправить в играх