Абстракции, наследование и полиморфизм

реклама
Абстракции, наследование и полиморфизм
Введение
Есть определённый класс задач, с которыми структурное программирование
справляется плохо. Для борьбы с такими задачами и была изобретена объектноориентированная парадигма. На этой лекции мы рассмотрим одну из таких задач, а
так же увидим, за счёт чего ООП справляется с такими задачами лучше, чем
структурное программирование.
Проблема
Пусть перед нами стоит задача написать библиотеку для кодирования и
декодирования данных. Библиотека должна поддерживать несколько типов
кодирования: base64, quoted-printable, uue, lz. Этот список может расти.
Библиотека будет применяться в весьма разнообразных формах:

де/кодирования данных, находящихся в памяти;

де/кодирования больших объемов данных (не помещающихся в памяти)
on-line с чтением данных из файла и записью результата в другой файл.

on-line де/кодирования данных получаемых по сети.
Применяя структурный подход к проектированию нашей библиотеки,
пришлось бы реализовать подпрограммы для всех комбинаций типа кодирования,
способа получения входных данных и способа вывода результата:
encodeBase64MemoryToMemory(...);
encodeBase64MemoryToFile(...);
encodeBase64MemoryToNet(...);
encodeBase64FileToMemory(...);
...
Для нашего примера получается 4*3*3 = 36 подпрограмм. Такой код очень
сложно поддерживать. В частности, чтобы проверить корректность работы этого
кода, нужно проверить все 36 комбинаций. Добавление нового типа кодирования
также превращается в достаточно тяжёлую операцию – нужно создавать целых 9
новых подпрограмм, а потом ещё и тестировать каждую из них. Если же со
временем появится новый способ получения данных и записи результата, то
количество комбинаций возрастёт до 64. Уже достаточно очевидно, что
структурный подход в чистом виде неудовлетворительно справляется с
поставленной задачей. Нужен новый подход к решению.
Структурное программирование не справляется с задачами, которые
могут расширяться сразу в нескольких направлениях.
Абстракции
Попробуем забыть на время о тех выразительных средствах, которые нам
предоставляет наш любимый язык программирования, и пофантазировать о том,
каким должен быть язык, чтобы на нём было удобно решать подобные задачи.
На самом деле алгоритму кодирования совершенно не обязательно знать про
специфику получения данных. Алгоритм кодирования совершенно не зависит от
того, каким образом получаются данные и куда записывается результат. Если бы
как-то получилось отделить специфику чтения данных и записи результата от
самого алгоритма, получилось бы обойтись без большого количества похожих
подпрограмм.
Для отделения алгоритма от специфики конкретных случаев его
использования обычно вводят так называемые «абстракции». Абстракция – это
модель семейства объектов, в которой фиксируются существенные свойства и
связи объектов семейства, и игнорируются все другие его свойства и связи,
признаваемые «частными», несущественными.
В нашем примере нам помогут абстракции под условными названиями
«Reader» и «Writer». Reader – это нечто, что умеет читать байт за байтом из
некоторого источника. Writer – это, соответственно, нечто, что умеет писать байт за
байтом в некоторое место. В этих терминах, для нашей задачи есть три Reader-а:

FileReader

MemoryReader

NetReader
– читает байты из файла;
– читает байты из некоторой области памяти (из буфера);
– читает байты из сети. (для тех, кто понимает, он должен был
называться SocketReader)
И соответственно есть три Writer-а:

FileWriter

MemoryWriter

NetWriter
– пишет байты в файл;
– пишет байты в некоторую область памяти (в буфер);
– пишет байты в сеть. (SocketWriter)
Хорошо бы, если бы наш язык программирования умел бы работать прямо с
абстракциями, то есть фактически, с парой абстрактных объектов Reader и Writer.
При вызове же алгоритма, нужно иметь возможность указывать алгоритму
произвольные конкретные реализации этих абстракций. Примерно так:
decodeBase64(someFileReader, someNetWriter);
Более того, наличие нескольких алгоритмов кодирования подталкивает нас к
выделению ещё одной абстракции Encoder. Encoder – нечто, что умеет кодировать
и декодировать данные по некоторому алгоритму. Как кодирование, так и
декодирование – это операции, с двумя аргументами – reader, из которого
получаются исходные данные и writer куда записывается результат. Введение такой
абстракции позволит единообразно писать код, работающий с различными видами
кодирования, точно также как и сами кодировщики единообразно работают с
разными реализациями абстракций Reader и Writer.
Итак, основная идея, которая здесь предлагается, звучит следующим образом:
Чтобы защитить код от изменчивости условий его использования, его
нужно писать так, чтобы он по возможности работал с
абстракциями, а не с конкретикой.
Абстракции в ОО-ЯП
Итак, чтобы язык мог успешно работать с абстракциями, в нём должны быть
должны быть возможности:

определять свои абстракции;

писать код, работающий непосредственно с абстракциями.

создавать конкретные реализации абстракций;
Рассмотрим, как эти возможности реализуются в объектно-ориентированных
языках программирования на примере языка Java.
Определение абстракций
Для определения абстракций используется понятие интерфейса. Определение
интерфейса очень похоже на упрощённое объявление класса. Определения
интерфейсов Reader и Writer на языке Java могут выглядеть так:
public interface Reader
{
byte readByte();
boolean hasMoreBytes();
}
public interface Writer
{
void writeByte(byte value);
void flush();
}
Как видно, вместо ключевого слова class используется ключевое слово
interface. Области видимости у методов не указываются – все методы интерфейсов
считаются публичными (public). Кроме того, у методов нет реализации.
Можно сказать, что интерфейс определяет контракт, который должен
выполняться любой реализацией этого интерфейса.
Абстракции формально определяются с помощью интерфейсов.
Реализация абстракций
На роль реализации абстракции идеально подходит класс, в котором
реализованы все методы, описанные в соответствующем интерфейсе. Этот факт
можно зафиксировать в заголовке объявления класса (ключевое слово implements).
В нашем примере, объявление класса FileReader может выглядеть так:
public class FileReader implements Reader
{
void read()
{
...
}
boolean hasMoreBytes()
{
...
}
}
В подобных случаях говорят, что класс реализует соответствующий
интерфейс. Для нашего примера можно сказать, что FileReader реализует
интерфейс Reader.
Классы могут реализовывать интерфейсы. Таким образом, классы –
это способ создания конкретных реализаций абстракций.
Совместимость по присваиванию
Мы определили нужные нам абстракции и создали их конкретные
реализации. Теперь у нас появилась возможность писать код, работающий с
абстракциями, а не с конкретными реализациями этих абстракций. Но перед тем,
как рассмотреть как это работает в языке Java, нужно уделить внимание системе
типов в объектно-ориентированных языках.
Раньше система типов была достаточно проста: был набор типов, и несколько
схем создания пользовательских типов (структуры, массивы, указатели). При этом
в переменной заданного типа могло содержаться значение только этого типа и
никакого другого.
В объектно-ориентированных языках программирования всё чуточку
сложнее. На множестве типов поддерживается отношение «совместимости по
присваиванию». Тип A совместим по присваиванию с типом B, тогда и только
тогда, когда переменной типа B можно присвоить значение типа A. На самом деле
это ещё означает и то, что все члены класса B также присутствуют и в классе A, то
есть фактически, объекты типа A можно использовать везде, где ожидаются
объекты типа B.
В переменной типа B можно сохранять любые значения всех типов,
совместимых по присваиванию с B.
Работа с абстракциями
Итак, абстракции определяются в виде интерфейсов. Интерфейсы в Java
являются полноценными типами данных. Причём каждая конкретная реализация
интерфейса совместима по присваиванию с самим интерфейсом. Другими словами
в переменной интерфейсного типа можно сохранять любые объекты, реализующие
этот интерфейс.
Рассмотрим пример алгоритма кодирования данных, работающего с
интерфейсами:
public void encodeXXX(Reader reader, Writer writer)
{
while (reader.hasMoreBytes())
{
byte b = reader.readByte();
byte encodedByte = encodeByteXXX(b);
writer.writeByte(encodedByte);
}
writer.close();
}
Тот факт, что метод использует интерфейсы означает, что при вызове в
качестве параметров ему можно указывать любые реализации интерфейсов Reader
и Writer, соответственно.
Возможность кода работать с объектами различного типа одинаковым
образом называется полиморфизмом. В нашем примере, алгоритм будет
полиморфно работать с объектами переданными в качестве параметров reader и
writer. Собственно, усложнение системы типов и введение совместимости по
присваиванию как раз и были нужны, для того, чтобы можно было создавать
полиморфный код.
Интерфейсы – полноценные типы данных. Классы, реализующие
некоторый интерфейс совместимы с ним по присваиванию. Это
позволяет работать с объектами этих классов полиморфно.
Наследование
Проблема
Механизм интерфейсов предлагает элегантный способ убрать лишние
зависимости алгоритмов кодирования от специфики получения исходных данных и
записи результата. Однако при усложнении нашего примера станет очевидным
недостаток такого подхода.
Для удобства написания новых кодировщиков нам могут потребоваться
некоторые вспомогательные методы в интерфейсах Reader и Writer. Например,
метод для чтения целого массива байтов или метода для чтения байтов в
определённое место массива. А также аналогичные методы в интерфейсе Writer:
public interface Reader
{
…
byte[] readBytes(int count);
void readBytes(byte[] buffer, int startIndex, int count);
}
public interface Writer
{
…
void writeBytes(byte[] buffer);
void writeBytes(byte[] buffer, int startIndex, int count);
}
Понятно, что все эти четыре метода нужны исключительно для удобства, так
как они без труда реализуются с помощью тех методов, которые уже были в
интерфейсах:
byte[] readBytes(int count)
{
byte[] buffer = new byte[count];
for(int i=0; i<count; i++)
buffer[i] = readByte();
return buffer;
}
Однако, при создании каждого класса, реализующего интерфейс Reader,
придётся заново реализовать и каждый из этих методов. А это совершенно
нежелательное дублирование кода.
Надо как-то бороться с дублированием кода при создании похожих
реализаций одного интерфейса.
Абстрактные классы
На помощь приходит так называемый механизм наследования. Идея
заключается в том, чтобы переместить те методы интерфейса, имеющие
одинаковую реализацию прямо в определение абстракции. В нашем случае, указать
реализацию методов writeBytes и readBytes непосредственно в определении
абстракций Reader и Writer. Однако синтаксическая конструкция определения
интерфейса не позволяет нам сделать этого.
На помощь приходит ещё один способ определения абстракций – абстрактные
классы. Их можно мыслить как классы, в которых некоторые методы не имеют
реализации (как в интерфейсах). Такие методы называются абстрактными. Класс
является абстрактным, если у него есть хотя бы один абстрактный метод.
public class Reader
{
public abstract byte readByte();
byte[] readBytes(int count)
{
byte[] buffer = new byte[count];
for(int i=0; i<count; i++)
buffer[i] = readByte();
return buffer;
}
…
}
Определения конкретных реализаций выглядит очень похоже на то как, мы
это делали с интерфейсами.
public class FileReader extends Reader
{
public byte readByte()
{
…
}
…
}
В заголовке с помощью ключевого слова extends необходимо указать, что
класс FileReader расширяет класс Reader, а в теле определения класса определить
реализацию всех абстрактных методов класса Reader.
С одной стороны, абстрактный класс является классом, с другой стороны он
содержит абстрактные методы, поэтому чтобы избежать неопределенностей
запрещёно создавать экземпляры абстрактных классов (точно также как нельзя
создавать экземпляры интерфейсов). Создавать можно лишь конкретные
реализации - экземпляры классов наследников.
Если подвести предварительный итог, то получится следующая картина:
1.
Абстракции
скрывают
всю
существенные свойства объекта.
специфику,
фиксируя
лишь
2.
Для поддержки абстракций на уровне языка программирования,
существует синтаксическая конструкция интерфейс.
3.
Если во всех реализациях абстракции некоторые методы имеют
одинаковую реализацию, при использовании интерфейсов возникает
дублирование общего кода. Чтобы его предотвратить, можно
использовать второй способ определения абстракций – абстрактные
классы.
Терминология
По отношению к классу FileReader, класс Reader называется базовым
классом или суперклассом. В таком случае говорят, что класс FileReader является
наследником класса Reader.
Про метод readByte говорят, что он переопределяет или перекрывает
абстрактный метод базового класса.
Сложные вопросы, связанные с наследованием
Несколько абстракций в одном классе
Может понадобиться, чтобы один класс являлся реализацией нескольких
независимых абстракций. В таких случаях можно использовать тот факт, что класс
может реализовывать сразу несколько различных интерфейсов.
Некоторые языки программирования (например, С++) предоставляют
возможность множественного наследования. При множественном наследовании у
класса может быть более одного предка. В этом случае класс наследует методы
всех предков.
Множественное наследование реализовано в C++. Считается, что
множественное наследование – потенциальный источник ошибок, которые могут
возникнуть из-за наличия одинаковых имен методов в предках. В языке Java и в
недавно появившемся языке C# от множественного наследования было решено
отказаться.
Переопределение методов
Вообще говоря, переопределять можно не только абстрактные методы. В
общем случае методы, которые можно переопределять называют виртуальными. В
языке Java все методы являются виртуальными. В языках ObjectPascal, C++ и C#
виртуальные методы должны быть при объявлении помечены модификатором
virtual, а перекрывающие методы должны иметь модификатор override.
При переопределении неабстрактного метода, поведение базового класса
полностью заменяется соответствующим поведением класса наследника.
Запрет наследования
Иногда хочется быть уверенным, что у некоторого класса никогда не будет
наследников. В языке Java этого можно достичь, указав в заголовке класса
модификатор final. То же самое можно сделать и в языке C#, только уже при
помощи модификатора sealed.
В некоторых случаях хочется быть уверенным, что некоторый определённый
метод во всех наследниках будет работать одинаково. То есть не хочется запрещать
наследование, а хочется запретить переопределять заданный метод. Для этого либо
метод нужно сделать не виртуальным, либо, если это не возможно, пометить его
специальным модификатором: final в Java и sealed в C#.
Многоуровневое наследование
Наследование может быть многоуровневым. То есть класс являющийся
наследником так же может выступать в роли базового класса.
Пример, иллюстрирующий сложные вопросы наследования
public abstract class Encoder
{
public abstract void encode(Reader reader Writer writer);
....
}
public abstract class ByteToByteEncoder extends Encoder
{
public final void encode(Reader reader Writer writer)
{
while (reader.hasMoreBytes())
{
byte b = reader.readByte();
byte encodedByte = encodeByte(b);
writer.writeByte(encodedByte);
}
}
public abstract byte encodeByte(byte b);
....
}
public final class RotateEncoder extends ByteToByteEncoder
{
...
public byte encodeByte(byte b)
{
int x = ((int)b + shiftSize) % 256;
return (byte)x;
}
....
}
Скачать