Skip to content

Latest commit

 

History

History
1603 lines (1192 loc) · 102 KB

proglang4.md

File metadata and controls

1603 lines (1192 loc) · 102 KB

Массивы и структуры

Программирование — это сложно. Основные правила, на которых все строится, очень просты, но по мере разработки программа сама начинает вводить свои правила и законы. Таким образом, программист строит лабиринт, в котором сам же может и потеряться.
Marijn Haverbeke

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

Общий принцип тут в том, что под одним общим именем в программе хранится некий набор данных.

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

  1. существует ли естественный порядок для индексов, то есть можно ли переходить от одного индекса к следующему?
  2. если порядок задан, то является ли он полным, то есть следует ли из существования в массиве данных с индексами N и N+2 также существование и данных с индексом N+1
  3. существует ли способ перехода от одного элемента данных к следующему?
  4. возможно ли наличие в одном массиве данных разных типов?
  5. является ли набор индексов раз и навсегда определенным при описании массива или структуры?

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

Массивы

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

Естественное деление массивов - на одномерные и многомерные. Как правило, в языке программирования есть и те и другие. Однако, начиная с языка Pascal, появилась некоторая путаница. Никлас Вирт, будучи математиком, предложил вполне логичное рекурсивное определение массивов:

var v: array[0..10] of array[0..10] of integer;

var w: array[0..10] of array[0..10] of array[0..10] of integer;

var z: array[0..10,0..10] of integer;
(* z и v это одинаковые объявления *)

То есть двумерный массив в Pascal это массив одномерных массивов. Все было хорошо до тех пор, пока array[0..10,0..10] было просто сокращенной записью для array[0..10] of array[0..10]. В языке C# это не так.

for(int i=0; i < n; i++)
  for(int j=0; j < n; j++)
     a1[i,j] = i*j;
и
for(int i=0; i < n; i++)
  for(int j=0; j < n; j++)
     a2[i][j] = i*j;

совершенно разные вещи. Здесь a1 - двумерный массив, a2 - массив массивов.

Описания массивов

В языках L-типа при описании массива должен задаваться его размер. Тогда компилятор сможет выделить память для массива. В языках R-типа обычно выделяют 2 действия : объявление массива и конкретизация его размера.

С#:

int [] arr;
arr = new int[10];

Индексы массивов

В процессе эволюции языков немало споров было вокруг индексов. Первым камнем преткновения был вопрос о том, с какого числа начинать нумерацию элементов массива. Было предложено три решения: с 0, с 1 и с любого числа по выбору программиста. Почему возникли эти вопросы? Логично считать, что первый элемент массива имеет индекс 1, а последний элемент массива из n элементов имеет индекс n. Однако, это приводит к достаточно неприятным проблемам в случае, когда над индексами нужно производить какие-то действия.

var x:array [1..100] of integer;
function element(ix,iy:integer) :integer;
(* возвращает элемент массива x, находящийся на строке ix, столбце iy, представляя
    массив x как двумерную таблицу, записанную последовательно по строкам  *)
begin
   element := x[(ix-1)*10+iy];
end;

Обратите внимание на выражение ix-1. Если бы нумерация начиналась с 0, выражение было бы проще: ix * 10+iy. Надо сказать, что получив выражение (ix-1)*10+iy, компилятор вычтет из него 1 чтобы получить адрес первого элемента. Хорошо, если это выражение будет оптимизировано и приведено к виду ix * 10+iy. Но такая оптимизация возможна не всегда. Поэтому массивы, начинающиеся с 0 иногда приводят к более эффективному коду и не вынуждают программиста высчитывать, правильно ли отнята или прибавлена 1.

В языке Pascal было принято вроде бы правильное решение задавать первый индекс массива в явном виде. Пусть программист ставит 1 если хочет использовать натуральный счет, и 0 если ему нужна арифметика индексов. Но это решение привело к некоторым проблемам в ходе развития языка. Очень удобно передавать массивы в процедуры и функции, не задумываясь о совпадении размеров. В Pascal если параметр процедуры описан как array [1..100] of integer, то в качестве фактического параметра может быть использован только точно так же описанный массив. А нам хотелось бы иметь функцию, например, суммирования элементов массива, которая могла бы работать с массивом любого размера.

Есть два способа сделать это. Первый – передавать в явном или неявном виде обе границы – нижнюю и верхнюю. Второй – зафиксировать нижнюю границу равной 0 и передавать только одну границу – верхнюю. В языке Ada принят первый способ, а вот разработчики разных вариантов языка Pascal так и не смогли прийти к общему мнению. Оба способа имеют свои недостатки. В первом случае усложняются выражения для операций над индексами, так как вместо 0 придется писать что-то вроде low(x) чтобы получить значение нижней границы массива x.

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

На сегодняшний день индексация массивов начинаются с 0 в большинстве языков программирования, включая C++, Python, Java и Javascript. Индексация с 1 принята в языке Lua, а в Pascal можно начинать индекс с любого числа.

Доступ к элементам массива

Элемент массива может встречаться везде, где появляется обычная переменная, как в правой части, так и в левой. Начиная с языка Algol для индексации массивов используют квадратные скобки [], это стало уже традицией. В языке С появилось оригинальное нововведение: запись обращения к элементу массива ничем не отличается от обращения к указателю.

int a[10];
x = *a;
x=a[0];

Это может быть иногда удобно, но приводит к тому, что многие программисты на C не понимают разницу между

char *string = “Hello,World”;

и

char string[] = “Hello,World”;

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

string++;

В первом случае это сработает, во втором нет.

Массивы и for

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

sum = 0
for i in range(0,5):
  sum = sum + a[i]

print(sum)

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

$a = [1,2,3];

$sum = 0;

foreach($a as $i)
   $sum = $sum + $i;

print($sum);

Специальная форма оператора for под названием foreach или for_each в некоторых языках, позволяет перебирать элементы массива, не обращая внимания на их индексы.

Операции над массивами

Понятие «операции над массивами» может иметь различный смысл. Во-первых, это может быть syntax sugar для циклов. Например, запись

A=B+C

может означать «к каждому элементу массива B прибавить элемент массива C с соответствующим номером, а результат записать в такой же по номеру элемент массива A.

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

Чаще других реализуют операцию присваивания массивов

A=B

Во-вторых, можно определить самостоятельные операции, определяемые над типом «массив». Например, + может означать сцепление двух массивов в один. Такой смысл имеет широко распространенная операция сцепления строк. Ее часто ассоциируют со сложением.

string a = b+c;

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

Массивы в функциях и процедурах

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

      SUBROUTINE S(A,N)
      REAL A(N)
...

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

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

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

Modula-2:

PROCEDURE Display(a : ARRAY OF INTEGER);
VAR Index : CARDINAL;
BEGIN
  FOR Index := 0 TO HIGH(a) DO
...
  END;
END;

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

  1. Передача ссылки на массив и передача размера массива в явном виде как отдельного параметра (C, Fortran)
  2. Неявная передача границ массива вместе с массивом (Modula-2, Ada, Delphi)
  3. Передача массива строго определенного размера (стандартный Pascal).

Структуры

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

struct rectangle
{
  int x,y;            // координаты
  int width,height;   // длина и ширина
  color c;            // цвет
  float rotation;     // угол поворота
};

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

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

Описание структур

В языках PL/1 и COBOL структуры описывались следующим образом:

Cobol:

DECLARE 01 CUSTOMER
  02 NAME
    03 FIRSTNAME CHAR(100)
    03 MIDDLEINITIAL CHAR
    03 LASTNAME CHAR(100)
  02 SALARY FLOAT

то же самое на языке С:

struct customer
{
  struct name
  {
    char[100] firstname;
    char middleinitial;
    char[100] lastname;
  };
  double salary;
};

и на Pascal:

type customer = record
  name : record
    firstname : array[1..100] of char;
    middleinitial : char;
    lastname : array[1..100] of char;
  end;
  salary : real;
end;

Как правило, в современных языках программирования используются какие-то вариации на тему C и Pascal, однако в функциональных языках возможно некоторое разнообразие. В языке Elixir структуры описываются так:

defmodule User do
  defstruct first_name: "John", last_name: "Brown", age: 27
end

new_user = %User{first_name: "Georgia", last_name: "Brown"}

Ничего необычного здесь, вроде бы, нет, кроме того, что для first_name, last_name и age заданы значения по умолчанию. Однако, если мы попробуем выяснить тип переменной new_user, то это будет map. Что такое map, мы выясним чуть позже, а сейчас нужно понять, что в разных языках программирования разные с точки зрения реализации вещи могут называться одинаково и наоборот, одинаковые реализации могут носить разные названия. На всякий случай перечислим наиболее часто встречающиеся названия составных типов в языках, как на русском, так и на английском языке.

массив, структура, список, последовательность, вектор, ассоциативный массив, множество, словарь

vector, array, structure, list, sequence, map, dictionary, associative array, set

Обращение к элементам структур

Если где-то имеется переменная с типом структуры, то обращение к ее элементам в большинстве языков более или менее стандартно — имя элемента пишется через точку:

C:

customer Ivan;
Ivan.name.middleinitial = 'И';
Ivan.salary = 10000.0;
user = %User{first_name: "Ivan", last_name: "Smith", age: 33}
IO.Puts(user.first_name)
IO.Puts(user.last_name)

Кроме этого, в C и C++ есть операция -> (стрелка), которая означает получение элемента структуры по ссылке на эту структуру. Интересно, что в C++ операцию . (точка) нельзя переопределить для других типов, а стрелку -> - можно.

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

Присваивание структур

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

C:

struct Point
{
  int x,y;
};

static struct Point pt = {10,20};
struct Point x = pt;

Хотя в языке C нет присваивания массивов, там есть присваивание структур. Парадоксально, но в языке C присваивание массивов возможно, если они являются элементами структур.

#include <stdio.h>

struct InArr
{
  int x,y;
  int a[2];
};

int main()
{
    struct InArr pt;
    pt.a[0]=33;
    pt.a[1]=34;

    struct InArr x;
    x.a[0]=0;
    x.a[1]=0;

    x = pt;

    printf("%d %d\n", x.a[0], pt.a[1]); // напечатает 33 34

    int a[2];
    int b[2];

    // a=b;  // ошибка!

}

Если у нас есть переменные-структуры, то у них есть адреса в памяти, следовательно, можно сделать указатели на структуры.

Использование структур

Польза от структур в основном выражается в двух совершенно не связанных областях. Во-первых, структуры позволяют уменьшить количество имен, с которыми приходится иметь дело «на верхнем уровне». Предположим, у нас есть какое-то количество точек на плоскости (как в предыдущем примере). У каждой точки есть две координаты, х и y. Если бы структур не было, пришлось бы заводить по два массива на каждый набор точек, по две переменных на каждую точку, и со всем этим как-то разбираться. Структуры позволяют «спрятать» одинаковые элементы внутрь, и работать с единым элементом «точка». Это уменьшает пространство имен и одновременно улучшает понимание, ведь всегда удобнее мыслить категориями «время», «точка на плоскости», чем «х-координата точки и y-координата этой же точки в другой переменной».

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

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

C:

struct list
{
  int value;
  struct list* next;
};

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

C:

int sum(struct list* i)
{
    if(i == NULL) return 0;
    return i->value + sum(i->next);
}

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

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

C:

// добавление элемента
struct list* push(struct list* i, int value)
{
    struct list* t = (struct list*) malloc(sizeof( struct list ));
    t->next = i;
    t->value = value;
    return t;
}

// удаление элемента
struct list* pop(struct list* i)
{
    if(i == NULL) return NULL;
    struct list* t = i->next;
    free(i);
    return t;
}

// печать списка
void print(struct list* l)
{
    while(l)
    {
        printf("%d ",l->value);
        l = l->next;
    }

    printf("\n");
}

// использование всего этого
int main()
{
  struct list *l = 0;

  l = push(l,33);
  l = push(l,34);
  l = push(l,35);

  print(l);  // напечатает 35 34 33

  l = pop(l);

  print(l); // напечатает 34 33

  printf("%d\n",sum(l)); // напечатает 67

}

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

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

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

Списки

В некоторых языках списки встроены в синтаксис. Термин "список" мы будем понимать как нечто, аналогичное массиву, но без индекса. Для списка должны быть определены три операции: получения первого элемента списка, получения остатка списка без первого элемента и получения нового списка из первого элемента и списка путем присоединения этого элемента в начало списка.

Нельзя сразу получить N-й элемент списка, но можно отсчитать N элементов с начала списка. И эту операцию часто встраивают в язык, так что список может быть неотличим от массива.

list = [1, 2, 3]
(write (list 1 2 3 ))

Стандартные названия для трех списочных операций пришли из языка Lisp. Его название это сокращение от LISt Processor, так что списки составляют основу этого языка. Функция, возвращающая первый элемент списка, называется CAR, функция для получения списка без первого элемента называется CDR, а функция, присоединяющая первый элемент к списку, носит название CONS. Однако, в языках семейства ML по каким-то причинам первые две функции называются hd и tl (от слов head и tail).

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

Для списков также существуют стандартные операции map, reduce и filter.

Аргументы map — список и функция с одним аргументом. В результате выполнения операции map функция вызывается для каждого элемента и из результатов формируется новый список.

for(int i=0; i < n; ++i)
    result[i] = f(a[i])

Результат операции map — список того же размера, что и исходный. В языках L-типа элементы списка должны быть одинакового типа, что автоматически достигается при объявлении типа элементов.

PHP

function cube($n)
{
   return($n * $n * $n);
}

$a = array(1, 2, 3, 4, 5);
$b = array_map("cube", $a);

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

for(int i=0;i<n;i++)
    result = f(result,a[i])

Результат операции — скаляр или список меньшей размерности.

PHP

function rsum($v, $w)
{
    return $v + $w;
}

$a = array(1, 2, 3, 4, 5);
$b = array_reduce($a, "rsum");

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

function rfilt($v)
{
    if ($v == 2) return true;
    if ($v == 5) return true;
    return false;
}

$a = array(1, 2, 3, 4, 5);
$b = array_filter($a, "rfilt");

Последовательности

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

Например, в языке Python добавление элемента в последовательность производится вызовом append, удаление называется del, а длину можно узнать при помощи функции len.

a = []
a.append(22)
a.append(33)
a.append(45)

print(a)        # [22, 33, 45]
print(len(a))   # 3

del a[1]

print(a)        # [22, 45]
print(len(a))   # 2

В языке Lua длину последовательности можно узнать при помощи операции #, добавление элемента в последовательность производится присваиванием элементу, следующему за последним, а удалить элемент можно, используя table.remove

a = {}

a[#a+1] = 33
a[#a+1] = 44
a[#a+1] = 55

print(unpack(a)) -- 33  44  55

print(#a)        -- 3

table.remove(a,2)

print(unpack(a)) -- 33 55

print(#a)        -- 2

Вызов функции unpack тут нужен только для красивой печати значений.

В Javascript добавление элемента называется push, удаление delete, а длину можно получить, используя length

a = [];


a.push(33);
a.push(44);
a.push(55);

console.log(a);         // [ 33, 44, 55 ]

console.log(a.length);  // 3

delete(a[1]);

console.log(a);         // [ 33, , 55 ]

console.log(a.length);  // 3

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

a = [1,1,2,2,3,3,4,4,5,5];

for(i = 0; i < a.length;++i){
  if(a[i] == 3)
    delete(a[i]);
}

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

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

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

a = [1,1,2,2,3,3,4,4,5,5];

var t = [];
for(i = 0; i < a.length;++i){
  if(a[i] != 3)
    t.push(a[
      i]);
}
a = t;

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

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

Вырезки из последовательности

Удалим элемент из последовательности.

Python

a = [ 33, 44, 55 ]

print(a[0:1]) # [33]
print(a[2:3]) # [55]

a = a[0:1]+a[2:3]

print(a)  # [33, 55]

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

a = [ 33, 44, 55 ];
a.splice(1,1);   // удаляем один элемент, начиная с первого

console.log(a);  // [ 33, 55 ]

console.log(a.length);  // 2

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

Кортежи

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

a = (1,"word",2.5)

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

Словари

В языке Python, PHP и некоторых других есть еще один тип данных, похожий на массив - словари или ассоциативные массивы.

$a = array("one"=>"один", "two"=>"два", "three" => "три");

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

a = {1:"one",20:"twenty",5:"five"}
print(a[1])
print(a[2]) # ошибка

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

a = {1:"one",20:"twenty",5:"five"}
k = a.keys()
for key in k:
  print(key)

Составные данные в языках Javascript и Lua

В языках программирования Javascript и Lua сделан еще один шаг в сторону унификации всех составных типов. В этих языках записи

a["x"]

и

a.x

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

Сводная таблица составных типов

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

  1. Массив - набор из фиксированного числа однородных элементов, доступ к которым происходит по числовому индексу.
  2. Структура - набор из фиксированного числа разнотипных элементов, доступ к которым происходит по имени.
  3. Последовательность - набор из переменного числа однотипных или разнотипных элементов, доступ к которым происходит по числовому индексу. Возможно добавление и удаление элементов.
  4. Кортеж - набор из фиксированного числа однотипных или разнотипных элементов, доступ к которым происходит по числовому индексу. Удаление, добавление и изменение элементов невозможны.
  5. Словарь - набор из переменного числа однотипных или разнотипных элементов, доступ к которым происходит по индексу произвольного типа. Возможно добавление и удаление элементов.

ООП

Магия перестаёт существовать после того, как вы понимаете, как она работает.
Тим Бернерс-Ли

ООП как Syntax Sugar

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

window_move(window* w, int x, int y);
window_size(window* w, int width, int height);
window_color(window* w, int color);

С точки зрения естественного языка это выглядит как тавтолгия: Окно_Переместить(Вот это окно, ...) Однако, если назвать функции просто move, size и color, легче не станет, потому что эти понятия слишком неспецифичные, мало ли что можно переместить или покрасить. Давайте введем такую договоренность: запись

window.move(x,y);

это то же самое, что и

move(window,x,y);

Правда же, это просто? И из вот этой идеи в результате выросла одна из самых сложных и мощных концепций программирования. Хотя, на самом деле, все было не так. Первым объектно-ориентированным языком был язык Simula-67, специально придуманный как язык моделирования, в соответствии с теоретическими построениями. А то, что идею ООП можно выразить вот такими простыми средствами, придумали уже потом.

Давайте посмотрим, что еще можно сделать с этой идеей.

Полиморфизм и методы

При наличии такой записи нет надобности называть функции различными именами, вроде window_move, компилятор сам разберется, что имелось в виду. window.move и car.move могут существенно отличаться, при этом у программиста тоже не возникает проблем с пониманием того, какая функция имелась в виду.

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

  class val{
      int data;
      public void print(){
        System.out.println(data);
      }
  };
  ...
  val a;
  ...
  a.print();

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

Экземпляры класса и this

В приведенном примере метод print обращается к переменной data без всякого объявления, как если бы все элементы структуры были описаны внутри метода как переменные. Не во всех языках это так, в PHP и Javascript придется использовать специальное ключевое слово.

class Val {

    public $data = 42;

    function print() {
        echo "My value is " . $this->data;
    }
}

$x = new Val;
$x->print();
a = {
  data : 42,
  print:function(){
     console.log("My value is " + this.data);
  }
}

a.print();

Вспомним, что мы имеем в виду под вызовом x.print(). Это просто другая запись для print(x). И вот этот самый x, который в скрытом виде передается функции print, внутри нее обозначается словом this.

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

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

class Val:
  def __init__(self):
    self.data = 42

  def print (this):  # не делайте так!
    print("My value is ",this.data)

x = Val()
x.print()

Принято (и не надо нарушать это правило, как мы это сейчас сделали) называть этот параметр self, хотя язык позволяет давать ему любое имя. Еще одно нововведение, которое мы тут видим, это метод со странным именем __init__, который автоматически вызывается при создании экземпляра объекта. Такой метод называется конструктор, и он очень удобен для присваивания начальных значений данным, поэтому он есть почти во всех объектно-ориентированных языках. Название его везде разное, но в целом есть три основных способа сказать, что данный метод является конструктором. В языке Python используется специальное имя, в Delphi есть ключевое слово constructor, а в C++, Java и других производных от C языках конструктором является тот метод, имя которого совпадает с именем класса.

class Val {
public:  
   int data;
   Val(){        // конструктор
     data = 42;
   }
   void print(){
     cout << data << endl;
   }
};

Для создания экземпляра класса традиционно используется ключевое слово (или функция) new. x = new C() означает буквально следующее: выделить память под структуру и вызвать конструктор, который заполнит там поля. В языке Python вместо этого нужно использовать имя класса как функцию: x = C().

Инкапсуляция

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

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

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

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

class Window{
    int coord_x,coord_y;
    ... // описание каких-то еще данных
    public:
    Window(int x,int y,int height,int width);
    void move(int dx,int dy);
};

class ColorWindow: public Window{
    Color window_color; // цвет
    public:
    ColorWindow(int x,int y,int height,int width,Color c);
    void setColor(Color c);
};

ColorWindow cw = new ColorWindow(10,10,640,480,BLUE);
cw.move(20,20);
cw.setColor(GREEN);

Здесь метод move в классе ColorWindow достался в наследство от класса Window, метод setColor был добавлен, поскольку для класса Window он не имел бы смысла, а конструктор был изменен.

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

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

class Figure:
    # Здесь мы описываем
    # наиболее общие свойства геометрических фигур.
    # У них есть координаты
    def setCoord(self,X,Y):
        self.x = X
        self.y = Y

    def getCoord(self):
        return (self.x,self.y)

    def __init__(self):
        self.x, self.y = 0,0

class Triangle (Figure):   # Треугольник это такая фигура
    # У треугольника есть все, что есть у фигуры, и кроме того
    def setVertices(self,P1,P2,P3):
        self.vertices = [P1,P2,P3]

    def getVertices():
        return self.vertices

    def __init__(self,P1,P2,P3):
        Figure.__init__(self)
        self.setVertices(P1,P2,P3)

    def Area(self):
        pass


class Circle (Figure):   # Круг это тоже такая фигура
    # У треугольника есть все, что есть у фигуры, и кроме того
    def setRadius(self,R):
        self.radius = R

    def getRadius():
        return self.radius

    def __init__(self,R):
        Figure.__init__(self)
        self.setRadius(R)

    def Area(self):
        pass

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

a = [Circle(10),Triangle((10,10),(32,10),(50,10)),Circle(20)]

for f in a:
    print(f.Area())

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

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

В языках L-типа ситуация немного сложнее. Так как тип данных должен быть полностью известен во время компиляции, декларируется, что "ссылки на типы объектов совместимы по присваиванию сверху вниз". Это означает следующее: мы не можем просто так взять и скопировать объект с большим количеством данных в объект с меньшим количеством данных. Однако, мы всегда можем разместить эти данные в памяти так, чтобы метод объекта базового класса, получив ссылку, "думал", что он работает со "своим" объектом. То есть переменной, которой можно присвоить ссылку на объект базового класса всегда также можно присвоить и ссылку на объект производного класса (но не наоборот!). Это позволяет нам работать с иерархиями объектов. Но при этом возникает одна проблема. Посмотрите еще раз на описание Figure, Circle и Triangle. Вычисление площади неизвестно какой фигуры не имеет смысла. А компилятор, рассматривая базовый класс Figure, не должен ничего знать про то, что мы где-то когда-то напишем метод, вычисляющий площадь конкретной фигуры. Figure - вещь слишком абстрактная, чтобы иметь хоть какую-то площадь.

Для таких случаев придуман механизм абстрактных классов. Если в языке такой механизм есть, можно объявить внутри Figure метод Area (и компилятор будет знать, что такой у нас есть), но объявить его абстрактным (abstract), чтобы компилятор знал, что реализация метода будет где-то в наследниках данного класса. Заодно наличие хоть одного абстрактного метода говорит о том, что создание экземпляров объектов такого класса не имеет смысла.

Перепишем наш пример на Java:

abstract class Figure{
    // Здесь мы описываем
    //наиболее общие свойства геометрических фигур.
    // У них есть координаты
    int x,y;

    void setCoord(int X,int Y){
        x = X;
        y = Y;
    }

    Point getCoord(){
        return new Point (x,y);
    }

    Figure(){
        x = 0; y = 0;
    }

    abstract int Area(); // абстрактный метод

};

class Triangle extends Figure{   // Треугольник это такая фигура

    Point vertices[];

    void setVertices(Point P1,Point P2,Point P3){
        vertices[0] = P1;
        vertices[1] = P2;
        vertices[2] = P3;
    }

    Point[] getVertices(){
        return vertices;
    }

    Triangle(Point P1,Point P2,Point P3){
        super();
        setVertices(P1,P2,P3);
    }


    @Override
    int Area(){
        return 0;
    }

}

class Circle extends Figure{   // Круг это тоже такая фигура

    int radius;

    final void setRadius(int R){
        radius = R;
    }

    int getRadius(){
        return radius;
    }

    Circle(int R){
        super();
        setRadius(R);
    }

    @Override
    int Area(){
        return 0;
    }
}

Здесь мы видим некоторые нововведения по сравнению с вариантом на Python. Во-первых, класс Figure объявлен как абстрактный. И в нем есть абстрактный же метод Area. Во-вторых, в производных классах добавлено (необязательное в Java) описание @Override. Это способ сказать компилятору, что у нас тут имеется конкретизация ранее объявленного абстрактного метода.

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

Объектно-ориентированное проектирование

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

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

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

Модели ООП

Как мы только что видели, в языках L-типа и R-типа вызовы методов объекта немного отличаются. В Java компилятор даст вызвать только те методы, которые заранее описаны для данного объекта. В Python можно вызвать любой метод объекта, и если он у объекта есть, он выполнится, а если его нет, возникнет ошибка. Для такого случая придуман специальный термин "посылка сообщений объекту". В языке Smalltalk это единственный метод для работы с объектами.

Transcript open.
Transcript show: 'Hello world'.

В первой строке мы посылаем объекту Transcript сообщение open, во второй - сообщение show с параметром 'Hello world'.

Полезность такого подхода особенно проявляется при работе с графическими интерфейсами (GUI). При этом в системе существует набор сущностей (окна, кнопки, линейки прокрутки итп), которыми управляет программа, и набор возможных внешних воздействий, таких как нажатие кнопки мыши, перемещение мыши, нажатие клавиши на клавиатуре, срабатывание таймера. Возникает вопрос: как информацию обо всех этих событиях события передать программе? Очевидно, хотелось бы, чтобы каждое такое событие приводило к вызову какого-то метода. Если наша программа написана на языке L-типа, то каждый такой метод должен быть где-то описан. В системе Windows количество возможных сообщений, приходящих от операционной системы к пользовательской программе, превышает 1000. Значит, где-то должен быть описан объект с 1000 методами, каждый из которых получает сообщение и ничего не делает. Тогда в производном классе можно будет переопределить методы и в них делать то, что нужно. Это не очень хороший подход. Обычно используют другие, например, в явном виде связывают метод класса с сообщением.

void my_callback( Fl_Widget* o, void*  ){
    // получает управление при нажатии на кнопку
}


 Fl_Button btn( 10, 150, 70, 30, "Click me" );
 ...
 btn.callback( my_callback );

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

В Python и других языках R-типа все намного лучше. В некоторых библиотеках GUI нужно в явном виде связывать тип события с обработчиком, например, в wxPython:

...
# добавляем пункт в меню
exitItem = fileMenu.Append(wx.ID_EXIT)
...
# связываем его с обработчиком
self.Bind(wx.EVT_MENU, self.OnExit,  exitItem)
...
# а вот обработчик
def OnExit(self, event):
        """Close the frame, terminating the application."""
        self.Close(True)

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

# библиотека libui
class MyWindow(Window):
    ...
    def onClose(self, data):
        super().onClose(data)
        app.stop()

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

ЕСЛИ в связанном с событием объекте есть метод с данным именем, ТО вызвать этот метод ИНАЧЕ игнорировать сообщение

Интерфейсы и Duck typing

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

interface Moveable {
    void moveAbs(int x,int y);
    void moveRel(int dx,int dy);
}

class Triangle implements Moveable{
    void moveAbs(int x,int y){
        ...
    }
    void moveRel(int dx,int dy){
        ...
    }
}

Здесь мы описали интерфейс, не связанный по сути своей с геометрическими фигурами на плоскости, и описали фигуру, которая реализует этот интерфейс. Это значит, что методы moveAbs и moveRel обязаны там присутствовать (иначе компилятор выдаст ошибку). Теперь мы можем написать программу, которая будет использовать наш интерфейс, не зная ничего о реальном типе переменной.

...
    void moveByPath(Moveable something, Point [] path){
        for(Point pt:path){
            something.moveAbs(pt.x,pt.y);
        }
    }
...

Имея информацию об интерфейсе, компилятор может решить, какие методы надо вызывать в данном случае. В языках R-типа такие описания не нужны, и вместо этого применяется концепция, которая называется duck typing: объект относится к некоторому классу, если он реализует все методы, которые реализует этот класс. "Если оно крякает как утка и ходит как утка и плавает как утка, то это утка и есть". Это приводит к несколько размытому представлению о том, что может делать объект, и немного мешает взаимодействию между программистами. В языке Javascript, например, ничто не мешает сделать объект, у которого будет один набор методов по понедельникам, другой по вторникам и так далее, и сочинитель такого объекта даже сможет его использовать, но попробуйте объяснить необходимость такого странного решения другим людям. Вряд ли они согласятся.

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

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

Другой вид наследования называется наследованием прототипов и согласно ему, производный объект должен быть "совсем как вон тот объект, но еще должен уметь то-то и то-то". В языке Javascript никаких описаний классов нет, вместо этого конструктор просто создает объект, добавляя в него нужные свойства и методы.

class Circle {
  constructor(center, radius) {
    this.center = center;  // свойства center и radius нигде заранее не были описаны
    this.radius = radius;  // в отличие от C++ и Java
    this.area = function(){
        return 3.14*radius*radius;
    }
  }
  ...
}

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

Методы класса и методы экземпляра

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

class Rectangle:
    def __init__(self,X,Y,H,W):
        self.coord = (X,Y)
        self.height = H
        self.width = W

    def print(self):
        print("Rectangle "+str(self.width)+"x"+str(self.height))

    @classmethod
    def square(cls,X,Y,S):
        return cls(X,Y,S,S)

r1 = Rectangle(10,10,50,80)      #создает прямоугольник 50x80
r2 = Rectangle.square(10,10,50)  #создает квадрат 50x80

r1.print()
r2.print()

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

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

Другая сторона особенностей реализации ООП в языках L-типа состоит в том, что когда компилятору заранее известен класс объекта, можно не выяснять, экземпляр какого класса в действительности лежит по ссылке, и вызвать тот метод, который описан в декларации класса. Так как разработчики C++ стремились прежде всего к эффективности и скорости, этот способ в языке C++ используется по умолчанию. Например:

class Base {
public:
    int x;
    void print(){
        cout << "I am base class\n" << endl;
    }
};

class Derived: public Base {
    void print(){
        cout << "I am derived class\n" << endl;
    }
};


...
   Base *b = new Derived();
   b->print();
...

В данном случае вызов b.print() приведет к печати строки "I am base class". Для того, чтобы был вызван метод того класса, объект которого в действительности лежит по ссылке, надо в описании метода добавить слово virtual. Эта особенность C++ доставляет много проблем программистам, которые из обычных руководств по этому языку выносят представление о том, что методы, описанные как virtual, это какие-то особенные методы, тогда как в действительности все обстоит наоборот, виртуальные методы C++ это с точки зрения большинства объектно-ориентированных языков, совершенно обычные, стандартные методы, тогда как не-виртуальные методы С++ это особенность языка, нужная для повышения производительности.

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

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

class A{
public:
    virtual void print(){
         cout << "I am A\n" << endl;
    }
};

class B: public A{
public:    
    virtual void print(){
         cout << "I am B\n" << endl;
    }
};

class C: public A{
public:    
    virtual void print(){
         cout << "I am C\n" << endl;
    }
};

class D: public B,C{

};

...
    A* d = new D();
    d->print();
...

Попытка скомпилировать эту программу приводит к ошибке, компилятор не может решить, какой метод использовать. Если добавить в D добавить метод print, все будет интереснее. Строка

A* d = new D();

не будет скомпилирована, а строка

D* d = new D();

будет работать. Как же так? Мы уже привыкли, что ссылки на объекты базового класса совместимы со ссылками на объекты производных классов.

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

Множественного наследования нет в Java, C#, Ruby и некоторых других языках. Создатели Java утверждают, что любая программа, написанная с использованием множественного наследования, может быть переписана с использованием интерфейсов.

Мультиметоды

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

var letters = ["Alpha", "Beta", "Gamma", "Delta"];
var alphabet = letters.join(","); // "Alpha,Beta,Gamma,Delta"

А в языке Python это метод класса String

letters = ["Alpha", "Beta", "Gamma", "Delta"]
alphabet = ",".join(letters)  # "Alpha,Beta,Gamma,Delta"

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

Примеси (миксины)

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

Решение этой проблемы называется mixin ("примесь"), и состоит в возможности написать интерфейс вместе с реализацией и включать это все в какие-то классы. Это похоже на множественное наследование, но, возможно, выглядит не так страшно.

Шаблоны проектирования

Самая главная особенность ООП это наличие очень развитой теоретической базы. На тему того, как лучше организовать взаимодействие между объектами и как реализовывать сами объекты, написано много книг. Одна из самых известных - книга "Приёмы объектно-ориентированного проектирования. Паттерны проектирования", которую написали Эрих Гамма, Ричард Хелм, Ральф Джонсон, и Джон Влиссидес, они же «Банда четырёх».

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

public class Singleton {
  private static Singleton instance;    // свойство instance и конструктор объявлены private
  private Singleton () {}               // ими могут пользоваться только методы этого класса

  public static Singleton getInstance() {  // все остальные могут использовать метод getInstance
    if (instance == null) {                // который при первом вызове сконструирует объект 
      instance = new Singleton();
    }
    return instance;                       // и возвратит его в качестве результата
  }
}

Принципы проектирования

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

Single Responsibility Principle - Принцип единственной ответственности. Это означает, что каждый класс должен выполнять только одну какую-то задачу. Это, наверное, самый простой принцип. Не надо делать класс, который будет делать вычисления и отображать результаты на экране. Лучше сделать два класса. Заметим, что многие системы быстрой разработки пользовательского интерфейса, такие как Delphi, сильно провоцируют нарушение принципа единственной ответственности. Когда система автоматически создает для программиста класс, представляющий собой окно программы, и в нем создает обработчик нажатия на кнопку "прочитать файл", естественно будет "научить" этот же класс читать файлы с диска. В итоге это приводит к коду, который сложно поддерживать и сложно развивать.

Open Closed Principle - Принцип открытости/закрытости. Это буквально значит следующее: «программные сущности должны быть открыты для расширения, но закрыты для модификации.» Если у вас есть уже работающий набор классов, и вам надо добавить в него дополнительную функциональность, то лучше делать это путем создания производных классов, а не созданием новой версии базового набора.

Liskov Substitution Principle - «объекты в программе должны быть заменяемыми на экземпляры их подтипов без изменения правильности выполнения программы.» Назван в честь Барбары Лисков. Идея состоит в том, что производный класс должен дополнять, а не изменять базовый. В определенном смысле этот вопрос касается терминологии. Если в базовом классе есть, например, метод print, служащий для печати сообщений, то в производном классе метод с именем print не должен, например, готовить к работе принтер.

Interface Segregation Principle - Принцип разделения интерфейса. «много интерфейсов, специально предназначенных для клиентов, лучше, чем один интерфейс общего назначения.» Другая формулировка это же принципа такова: при изменении метода класса не должны меняться программные сущности, которые этот метод не используют.

Dependency Inversion Principle - Принцип инверсии зависимостей. Идея состоит в том, что абстракции, специально придуманные для разработки программ, не должны зависеть от особенностей реализации. Также сущности верхнего уровня должны зависеть не от реализации сущностей нижнего уровня, а от абстракций.