Абстрагирование связано с тем, как данная проблема представлена в пространстве программы. Во-первых, абстрагирование заложено в самих языках программирования. Постарайтесь вспомнить, давно ли вам приходилось заботиться о стеке или регистрах процессора. Возможно, когда-то вы изучали программирование на ассемблере, но держу пари, что много воды утекло с тех пор, когда вас занимали детали реализации программы на низшем, машинно-зависимом уровне. Причина проста: большинство языков отстраняют вас (абстрагируют) от таких подробностей, позволяя сосредоточиться на решении прикладной задачи.
При объявлении классов в объектно-ориентированных языках вы можете использовать такие имена и интерфейсы, которые отражают смысл и назначение объектов предметной области. "Удаление" элементов, не связанных напрямую с решением задачи, позволит вам полностью сосредоточиться на самой задаче и решить ее более эффективно. Перефразируя высказывание из книги Брюса Эккеля (Вшсе Eckel) "Thinking in Java", можно сказать: в большинстве случаев умение достичь решения проблемы сводится к качеству применяемого абстрагирования.
Однако язык — это один уровень абстрагирования. Если вы пойдете дальше, то, как разработчику класса, вам нужно придумать такую степень абстрагирования, чтобы клиенты вашего класса могли сразу сосредоточиться на своей задаче, не тратя время на изучение работы класса. На очевидный вопрос — какое отношение интерфейс класса имеет к абстрагированию? — можно ответить так: интерфейс класса и есть реализация абстрагирования.
Чтобы обсуждаемые здесь идеи были понятней, воспользуюсь аналогией с работой внутренних устройств торговых автоматов. Описать подробно, что происходит внутри торгового автомата, довольно трудно. Чтобы выполнить свою задачу, автомат должен принять деньги, рассчитать, дать сдачу, а затем — требуемый товар. Однако покупателям — пользователям автомата видно лишь несколько его функций. Элементы интерфейса автомата: щель для приема денег, кнопки выбора товара, рычаг для запроса сдачи, лоток, куда поступает сдача, и желоб подачи товара. Торговые автоматы остаются без изменений (более или менее) со времени их изобретения. Это связано с тем, что их внутренняя организация совершенствовалась по мере развития технологии, а основной интерфейс не нуждался в больших переменах. Неотъемлемой частью проектирования интерфейса класса является достаточно глубокое понимание предметной области. Такое понимание поможет вам создать интерфейс, предоставляющий пользователям доступ к нужной им информации и методам, но изолирующий их от "внутренних органов" класса. При разработке интерфейса вы должны думать не только о решении текущей задачи, но и о том, чтобы обеспечить такое абстрагирование от внутреннего представления класса, которое позволит неограниченно модифицировать закрытые члены класса, не затрагивая существующего кода.
При определении нужной степени абстрагирования класса важно помнить и о программисте клиентского кода. Представьте, что вы пишете основное ядро базы данных. Возможно, вы прекрасно разбираетесь в таких понятиях БД, как
курсоры
(cursors),
управление фиксацией
Кроме того, решая, какие члены класса сделать открытыми, надо опять вспомнить о клиенте. Это еще раз подтверждает необходимость иметь хотя бы начальное представление о предметной области и клиентах вашего класса. Так, в случае с БД ваши клиенты, наверное, не должны иметь прямого доступа к членам, представляющим внутренние буферы данных. Ведь структура этих буферов может когда-нибудь измениться. Кроме того, от целостности этих буферов зависит вся работа ядра БД, и поэтому операции по их изменению следует выполнять только вашими методами. Только после этого можно сказать, что предприняты все меры предосторожности.
Важнейшую проблему "правильного" наследования я начну с термина
замещаемость
(substitutability), взятого у Маршалла Клайна (Marshall Cline) и Грега Ломау (Greg Lomow) (C++ FAQs, Addison-Wesley, 1998). Этот термин означает, что поведение производного класса достигается путем замещения поведения, заимствованного у базового класса. Это одно из важнейших правил, которое вам нужно соблюдать при построении
работающей
иерархии классов. (Под "работающими" я подразумеваю системы, выдержавшие проверку временем и оправдавшие надежды на повторное использование и расширение кода.)
А вот еще одно важное правило, которому я советую следовать при создании собственной иерархии классов:
любой унаследованный интерфейс производного класса не должен требовать больше и обещать меньше, чем в базовом классе.
Пренебрежение этим правилом приводит к разрушению существующего кода. Интерфейс класса — это контракт между классом и пользователями, применяющими этот класс. Имея ссылку на производный класс, программист всегда может обращаться с ним, как с базовым классом. Это называется
восходящим преобразованием типа
(upcasting). В нашем примере клиент, имея ссылку на объект
ContractEmp-loyee,
обладает и неявной ссылкой на его базовый класс — объект
Employee.
Поэтому согласно определению объект
ContractEmployee
всегда должен поддерживать выполнение функций своего базового класса. Заметьте: это правило распространяется только на функциональные возможности базового класса. В производный класс можно добавить и другие функции, которые выполняют и более узкие (или более широкие) задачи, чем унаследованные функции. Поэтому данное правило применяется только к унаследованным членам, поскольку существующий код рассчитан на работу только с этими членами.
В этой главе вы познакомитесь с терминологией объектно-ориентированного программирования (ООП) и убедитесь в важности применения в программировании объектно-ориентированных концепций. Бытует мнение, что во многих языках, таких как C++ и Microsoft Visual Basic, есть "поддержка объектов", однако на самом деле лишь немногие из них следуют всем принципам, составляющим основу ООП, и язык С# — один из них. Он изначально разрабатывался как настоящий объектно-ориентированный язык, в основе которого лежит технология компонентов. Поэтому, чтобы чтение этой книги принесло максимальную пользу, вам следует очень хорошо усвоить представленные здесь понятия.
Мне известно, что читатели, стремящиеся поскорее "окунуться" в код, часто пропускают такого рода концептуальные главы, однако тому, кто собирается стать "гуру объектов", настоятельно рекомендую прочитать эту главу. Представленные здесь сведения будут полезны и тем, кто только начинает знакомиться с ООП. Кроме того, имейте в виду, что в дальнейших главах мы будем оперировать терминами и понятиями, рассмотренными в данной главе.
Повторяю: многие языки претендуют называться "объектно-ориентированными;" либо "основанными на объектах", но лишь немногие являются таковыми на самом деле. Взять, например, C++. Ни для кого не секрет, что своими корнями он глубоко уходит в язык С, и ради поддержки программ, написанных когда-то на С, в нем пришлось пожертвовать очень многими идеями ООП. Даже в Java есть вещи, не позволяющие считать его по-настоящему объектно-ориентированным языком.
Прежде всего, я имею в виду
базисные типы
и
объекты,
которые обрабатываются и ведут себя по-разному. Однако в центре внимания этой главы будет не анализ того, насколько полно реализованы принципы ООП в тех или иных языках программирования, а объективное и фундаментальное изложение самих этих принципов.
Еще мне хотелось бы отметить, что объектно-ориентированное программирование — это не только модный термин (хотя для многих это именно так), не только новый синтаксис или новый интерфейс прикладного программирования (API). ООП — это целый набор концепций и идей, позволяющих осмыслить задачу, стоящую при разработке компьютерной программы, а затем найти путь к ее решению более понятным, а значит, и более эффективным способом.
Моя первая работа была связана с языком Pascal, на котором я писал прикладные программки по выпуску бухгалтерских отчетов и составлению маршрутов гастролей для балета на льду. Со временем я стал программировать на PL/I и RPGIII (и RPG/400), а потом и на С. В каждом случае мне было нетрудно применять знания, приобретенные ранее. Каждый следующий язык учить было проще — независимо от его сложности. Это связано с тем, что все языки до перехода к программированию на C++ были процедурными и отличались главным образом синтаксисом.
Сразу хочу предупредить новичков в ООП:
опыт, полученный при работе с не объектно-ориентированными языками, вам не пригодится.
Объектно-ориентированное программирование — это иной способ осмысления, формулирования и решения задач по созданию программ. Практика показывает, что начинающие программисты намного быстрее овладевают объектно-ориентированными языками, чем те, кто начинал с процедурных языков вроде BASIC, COBOL и С. Им не нужно "забывать" навыки работы с процедурами, которые лишь мешают в освоении ООП. Тут лучше всего начинать "с чистого листа". Если вы долгие годы программировали на процедурных языках и С# — ваш первый объектно-ориентированный язык, то советую набраться терпения и реализовать предлагаемые мной идеи еще до того, как у вас опустятся руки и вы скажете: "Пожалуй, я обойдусь и (вставьте сюда название своего любимого процедурного языка)". Но тот, кто прошел трудный путь от программирования процедур к ООП, скажет, что игра стоит свеч. У программирования на объектно-ориентированном языке масса преимуществ, причем это относится не только к созданию более эффективного кода, но и к модификации и расширению возможностей уже имеющихся систем. Многим поначалу такое утверждение не кажется столь очевидным. Однако почти 20 лет разработки ПО (включая 8 последних лет на объектно-ориентированных языках) убедили меня, что концепции ООП, применяемые с умом, действительно оправдывают возлагаемые на них надежды. А теперь, закатав рукава, разберемся, из-за чего весь сыр-бор.
Как я уже говорил, инкапсуляция, или
утаивание информации
(information hiding), — это возможность скрыть внутреннее устройство объекта от его пользователей, предоставив через интерфейс доступ только к тем членам объекта, с которыми клиенту разрешается работать напрямую. Поскольку в том же контексте я говорил также об
абстрагировании,
то считаю нужным пояснить разницу между этими похожими понятиями. Инкапсуляция подразумевает наличие границы между внешним интерфейсом класса (открытыми членами, видимыми пользователям класса) и деталями его внутренней реализации. Преимущество инкапсуляции для разработчика в том, что он может открыть те члены класса, которые будут оставаться статичными, или неизменяемыми, скрыв внутреннюю организацию класса, более динамичную и в большей степени подверженную изменениям. Как уже говорилось, в С# инкапсуляция достигается путем назначения каждому члену класса своего модификатора доступа —
public, private
или
protected.
В настоящем объектно-ориентированном языке все элементы так называемой
предметной области
(problem domain) выражаются через концепцию
объектов.
[В этой книге использовано определение Коуда-Йордо-на (Coad/Yourdon), согласно которому под предметной областью понимают решаемую задачу с учетом ее сложности, терминологии, подходов к ее решению и т. д.] Как вы уже, наверное, поняли, объекты — это центральная идея объектно-ориентированного программирования. Многие из нас, обдумывая какую-то проблему, вряд ли оперируют понятиями "структура", "пакет данных", "вызов функций" и "указатели", ведь привычнее применять понятие "объекты". Возьмем такой пример.
Допустим, вы создаете приложение для выписки счета-фактуры, в котором нужно подсчитать сумму по всем позициям. Какая из двух формулировок понятней с точки зрения пользователя?
Наследованием называют возможность при описании класса указывать на его
происхождение
(kind-of relationship) от другого класса. Наследование позволяет создать новый класс, в основу которого положен существующий. В полученный таким образом класс можно внести свои изменения, а затем создать новые объекты данного типа. Этот механизм лежит в основе создания иерархии классов. После абстрагирования наследование — наиболее значимая часть общего планирования системы.
Производным
(derived class) называется создаваемый класс, производный от
базового
(base class). Производный класс наследует все методы базового, позволяя задействовать результаты прежнего труда.
Наличие в классах абстрагирования, которое максимально удобно для программистов, работающих с этими классами, имеет первостепенное значение при разработке повторно используемого ПО. Если вы выстроите интерфейс, на который не влияют изменения в реализации, то вашему приложению долгое время не понадобятся никакие модификации. Вспомните пример с расчетом зарплаты. При работе с объектом
Employee
и функциями, обеспечивающими расчет зарплаты, клиенту нужны лишь несколько методов, таких как
CalculatePay, GetAddress
и
GetEmployeeType.
Если вы знакомы с предметной областью задачи, вы без труда определите, какие методы понадобятся пользователям класса. Скажем так: если при проектировании класса вам удается сочетать хорошее знание предметной области с прогнозом относительно дальнейших перспектив использования класса, можно гарантировать, что большая часть интерфейса этого класса останется неизменной, даже в случае возможного совершенствования реализации класса. В данном примере для пользователя главным является только класс
Employee,
в котором, с его точки зрения, от версии к версии лучше бы ничего не менять.
В результате отстранения пользователя от деталей реализации система в целом становится понятнее, а значит, и удобнее в работе. Иначе обстоит дело с такими процедурными языками как С, в которых нужно показать явно каждый модуль и предоставить доступ к элементам структуры. И при каждом ее изменении нужно редактировать строки кода, имеющие отношение к данной структуре.
Очевидно, что объектно-ориентированный подход естественнее и ближе к тому способу рассуждений, которым многие из нас руководствуются при решении задач. Во втором варианте объект "счет-фактура", наверно, просматривает в цикле
совокупность
(collection) объектов, представляющих данные по каждой позиции, посылая им запросы на получение суммы по данной позиции. Но если требуется получить только общий итог, то
вам все равно, как это реализовано,
так как одним из основных принципов объектно-ориентированного программирования является
инкапсуляция
(encapsulation). Инкапсуляция — это свойство объекта скрывать свои внутренние данные и методы, представляя наружу только интерфейс, через который осуществляется программный доступ к самым важным элементам объекта. Как объект выполняет задачу, не имеет значения, главное, чтобы он справлялся со своей работой. Имея в своем распоряжении интерфейс объекта, вы заставляете объект выполнять нужную вам работу. (Ниже я остановлюсь на понятиях "инкапсуляция" и "интерфейс".) Здесь важно отметить, что разработка и написание программ моделирования реальных объектов предметной области облегчается тем, что представить поведение таких объектов довольно просто.
Заметьте: во втором подходе от объекта требовалось, чтобы он произвел нужную вам работу, т. е. подсчитал общий итог. В отличие от структуры, в объект по определению входят не только данные, но и методы их обработки. Это значит, что при работе с некоторой проблемной областью можно не только создать нужные структуры данных, но и решить, какие методы связать с данным объектом, чтобы объект стал полностью инкапсулированной частью функциональности системы.
Программисты, начинающие осваивать ООП, часто путают термины "объект" и "класс". Чтобы показать их различия, введем в пример EmployeeApp возможность рассчитывать зарплату всему штату компании.
В С-программе мы начали бы с описания массива данных о служащих компании, взяв за основу структуру
EMPLOYEE.
Так как нам неизвестно число служащих компании в расчетный период, мы создали бы статический массив, состоящий, скажем, из 10 000 элементов. Однако когда в компании будет числиться только 1 служащий, такое использование памяти окажется весьма расточительным. Для более эффективного распределения ресурсов надо создать связанный список структур
EMPLOYEE
и по мере необходимости динамически изменять выделение памяти.
Но это именно то, чего, по-моему, делать не следует. Мы будем ломать голову над тем, сколько памяти перераспределить и когда это лучше сделать, вместо того, чтобы сконцентрироваться на предметной области. Обращение к объектной технологии позволит нам сосредоточиться на логике решения задачи, а не на механизме ее реализации.
Есть разные трактовки термина "класс", показывающие, в частности, чем класс отличается от объекта. Считайте, что класс — это просто новый тип данных (как
char, int
или
long),
с которым связаны некие методы. Объект же — это экземпляр типа, или класса. Но мне больше по душе определение класса как чертежа объекта. Как разработчик объекта, вы сначала создаете его "чертеж", так же как инженер-строитель чертит план дома. Имея такой чертеж, вы располагаете всего лишь проектом дома этого типа. Однако те, кто приобрел этот чертеж, могут по нему построить себе дом. Таким же образом на базе класса — "чертежа" набора функциональных возможностей — можно создать объект, обладающий всеми возможностями этого класса.
В этой главе на вас обрушился целый поток терминов и концепций ООП. Более углубленное изучение этой темы заняло бы еще не одну главу и отвлекло от основной цели этой книги. Однако только уверенное владение основами ООП поможет вам извлечь максимальную пользу из языка С#.
Мы затронули здесь несколько важных идей. Ключом к пониманию объектно-ориентированных систем является знание различий между классами, объектами и интерфейсами, а также умение применить эти концепции для получения эффективных решений. Качество объектно-ориентированных решений зависит и от разумной реализации трех принципов ООП: инкапсуляции, наследования и полиморфизма. Концепции, представленные в этой главе, закладывают фундамент для следующих глав, посвященных технологиям Microsoft .NET Framework и Common Language Runtime.
По-моему, самое короткое и выразительное определение полиморфизма таково: это функциональная возможность, позволяющая старому коду вызвать новый. Это свойство ООП, пожалуй, наиболее ценно, поскольку дает вам возможность расширять и совершенствовать свою систему, не затрагивая существующий код.
Предположим, вам нужно написать метод, в котором для каждого объекта из набора
Employee
вызывается метод
CakulatePay.
Все просто, если зарплата рассчитывается одним способом: вы можете сразу вставить в набор тип нужного объекта. Проблемы начинаются с появлением других форм оплаты. Допустим, у вас уже есть класс
Employee,
реализующий расчет зарплаты по фиксированному окладу. А что делать, чтобы рассчитать зарплату контрактников — ведь это уже другой способ расчета! В случае с процедурным языком вам пришлось бы переделать функцию, включив в нее новый тип обработки, так как в прежнем коде такой обработки нет. А объектно-ориентированный язык благодаря полиморфизму позволяет делать различную обработку.
В нашем примере надо описать базовый класс
Employee,
а затем создать производные от него классы для всех форм оплаты (упомянутых выше). Каждый производный класс будет иметь собственную реализацию метода
CakulatePay.
Здесь и начинается самое интересное. Возьмите указатель на объект, приведите его к типу-предку и вызовите метод этого объекта, а средства языка времени выполнения обеспечат вам, благодаря полиморфизму, вызов той версии этого метода, которая вам требуется. Поясним сказанное на примере.
using System;
class Employee
{
public Employee(string firstName, string lastName, int age, double payRate)
{
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
this.payRate = payRate; }
protected string firstName;
protected string lastName;
protected int age;
protected double payRate;
public virtual double CalculatePay(int hoursWorked)
{
Console.WriteLine("Employee.CalculatePay"); return 42; // произвольное число
} >
class SalariedEmployee : Employee {
public SalariedEmployee(string firstName, string lastName,
int age, double payRate) : base(firstName, lastName, age, payRate) {}
public override double CalculatePay(int hoursWorked) {
Console.WriteLine("SalariedEmployee.CalculatePay"); return 42; // произвольное число } } .
class ContractorEmployee : Employee {
public ContractorEmployee(string firstName, string lastName, int age, double payRate)
: base(firstName, lastName, age, payRate)
<}
public override double CalculatePay(int hoursWorked) {
Console.WriteLineC'ContractorEmployee.CalculatePay");
return 42; // произвольное число } }
class HourlyEmployee : Employee {
public HourlyEmployee(string firstName, string lastName, int age, double payRate)
: base(firstName, lastName, age, payRate)
{}
public override double CalculatePay(int hoursWorked) <
Console.WriteLine("Hou rlyEmployee.CalculatePay");
return 42; // произвольное число > }
class PolyApp {
protected Employee[] employees;
protected void LoadEmployeesQ
{
Console.WriteLine("Загрузка информации о сотрудниках...");
// В реальном приложении эти сведения мы // возьмем, наверное, из базы данных, employees = new Employee[3];
employees[0] = new SalariedEmployee ("Amy", "Ariderson", 28, 100);
employees[1] = new ContractorEmployee ("John", "Maffei", 35, 110); employees[2] = new HourlyEmployee ("Lani", "Ota", 2000, 5);
Console. Writel_ine( "\n"); }
protected void CalculatePayO .
{
foreach(Employee emp in employees)
<
emp.CalculatePay(40);
} }
public static void Main()
{
PolyApp app = new PolyAppQ;
app.LoadEmployees(); app. CalculatePayO; } }
В результате компиляции и запуска этого приложения будут получены такие результаты:
c:\>PolyApp
Загрузка информации о сотрудниках...
SalariedEmployee.CalculatePay ContractorEmployee.CalculatePay HourlyEmployee.CalculatePay
Полиморфизм имеет минимум два плюса. Во-первых, он позволяет группировать объекты, имеющие общий базовый класс, и последовательно (например, в цикле) их обрабатывать. В рассмотренном случае у меня три разных типа объектов
(SalariedEmployee, ContractorEmployee
и
Hourly-Employee),
но я вправе считать их все объектами
Employee,
поскольку они произведены от базового класса
Employee.
Поэтому их можно поместить в массив, описанный как массив объектов
Employee.
Во время выполнения вызов метода одного из этих объектов будет преобразован, благодаря полиморфизму, в вызов метода соответствующего производного объекта.
Второе достоинство я упоминал в начале этого раздела: старый код может использовать новый код. Заметьте: метод
PolyApp.Calculate Pay
перебирает в цикле элементы массива объектов
Employee.
Поскольку объекты приводятся неявно к вышестоящему типу
Employee,
а реализация полиморфизма во время выполнения обеспечивает вызов надлежащего метода, то ничто не мешает нам добавить в систему другие производные формы оплаты, вставить их в массив объектов
Employee,
и весь существующий код продолжит работу в своем первоначальном виде!
Реализация
(instantiation) в ООП означает факт создания экземпляра (он же объект) некоторого класса. В следующем примере мы создадим только класс, или
спецификацию
(specification), объекта. А поскольку это не сам объект, а лишь его "чертеж", то память для него не выделяется.
class Employee
{
public Employee(string firstName, string lastName, int age, double payRate)
{
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
this.payRate = payRate; }
protected string firstName;
protected string lastName;
protected int age;
protected double payRate;
public double CalculatePay(int hoursWorked)
{
// Здесь вычисляется зарплата, return (payRate * (double)hoursWorked); } }
Чтобы получить объект класса и начать
с
ним работу, мы должны объявить экземпляр класса в своем методе примерно так:
public static void Main()
{
Employee emp = new Employee ("Эми", "Андерсон", 28, 100);
}
В этом примере объявлена переменная
етр
типа
Employee,
и с помощью оператора
new
выполнена ее реализация. Переменная
етр
представляет собой экземпляр класса
Employee
и является объектом
Employee.
Выполнив реализацию объекта, мы можем установить с ним связь через его открытые (public) члены. Например, для объекта
етр
это метод Calcula-tePay. Пока реально объект не существует, вызывать его методы нельзя. (Есть, правда, одно исключение: мы можем вызывать статические члены. Но об этом мы поговорим в главах 5 и 6.) Взгляните на следующий код С#:
public static void MainQ {
Employee emp = new EmployeeQ;
Employee emp2 = new Employee(); }
Здесь два экземпляра одного класса
Employee — етр
и
етр2.
Оба объекта одинаковы с точки зрения программной реализации, но у каждого экземпляра свой набор данных, который может обрабатываться отдельно от другого. Аналогично можно создать массив или набор (collection) объектов
Employee.
Работу с массивами мы подробно рассмотрим в главе 7. Здесь же я хочу обратить ваше внимание на то, что большинство объектно-ориентированных языков поддерживает создание и обработку массивов объектов. При этом объекты можно объединять в группы и обрабатывать в операторах цикла, вызывая методы массива этих объектов либо обращаясь к элементам массива по индексу. Сравните это с той работой, которую нужно проделать со связанным списком, когда требуется вручную увязывать каждый элемент списка с предыдущим и последующим элементами.
По Бьерну Страуструпу, автору C++, язык может называться объектно-ориентированным, если в нем реализованы три концепции: объекты, классы и наследование. Однако теперь принято считать, что такие языки должны держаться на других трех китах:
инкапсуляции, наследовании
и
полиморфизме.
Этот философский сдвиг произошел из-за того, что со временем мы стали понимать: построить объектно-ориентированные системы без инкапсуляции и полиморфизма так же невозможно, как без классов и наследования.