Бьярн Страуструп.

Что такое объектно-ориентированное программирование.

На главную страницу сайта


Содержание


Введение

Содержание

О авторе

Введение

Bjarne Stroustrup является разработчиком языка С++. Его исследовательские интересы включают распределенные системы, операционные системы, моделирование, методологию программирования и языки программирования.

Stroustrup получил MS в области математики и вычислительной техники в университете Артуса (Aarhus) и PhD по вычислительной технике в университете Кэмбриджа (Cambridge). Он является членом исследовательского центра вычислительной техники (Computer Science Research Center), также членом IEEE и ACM.

О переводе

Введение

Оригинал перевода статьи был взят с сайта Programmers' Club. Я исправил ряд неточностей и опечаток в листингах и текст там, где, как мне показалось, он того требует. Я надеюсь, что смысл не исказился, хотя можно получить игру в испорченный телефон. Иногда я вставлял свое мнение по поводу отдельных мест перевода в виде комментария. Иногда, когда я не мог понять какие-то фразы, что же скрыто за этим набором слов и не мог подобрать альтернативный, более понятный вариант, я просто закомментировал это.

О статье

Введение

Ранняя версия этой статьи представлялась на совещании Ассоциации пользователей языка Simula-association of Simula Users - в Стокгольме, прошедшем в августе 1986 г. Прошедшая там дискуссия внесла много улучшений в стиль излождения и содержания статьи. Много конструктивных замечаний сделали Брайен Керниган (Brian Kernighan) и Рави Сети (Ravi Sethi).

Не все языки программирования могут быть объектно-ориентированными. Да, были сделаны утверждения, что APL, Ada, Clu, C++, Loops и Sмalltalk объектно-ориентированные языки. Мне приходилось слышать дискуссии об объектно-ориентированном программировании на C, Pascal, Modula-2 и Chill. Можно ли говорить об объектно-ориентированном программировании на языках Fortran и Cobol? Я думаю, что в общем то да.

Слово "объектно-ориентированный" стало в области программирования синонимом слово "хорошо". В печати чаще всего встречаются элементы такого вида: "Ada - это хорошо; объектная ориентированность - это хорошо; отсюда следует, что Ada - объектно-ориентированный язык".

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


Подходы в программировании

Содержание

О подходах в программировании

Подходы в программировании

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

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

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

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

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

Последние два принципа можно перефразировать как "то, чего вы не знаете, не должно вам навредить". Если имеются сомнения о полезности некоторого средства, лучше его опустить. Значительно легче давать к языку новую возможность, чем изъять ее или изменить, поскольку она реализована в компиляторе и описана в литературе.

Процедурность

Подходы в программировании

Изначальной и, вероятно, все еще самой общей парадигмой программирования является следующая: "Решите, какие процедуры вы желаете; используйте лучше из алгоритмов, которые можете найти".

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

Fortran является первым процедурным языком; Algol-60, Algol-68, С, Pascal - более поздние проекты, продолжающие эту традицию.

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

     double sqrt(double arg)
     {
        //команды для вычисления корня
     }

     void some_function()
     {
        double root2=sgrt(2);
        //...
     }

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

Локализация даных

Подходы в программировании

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

Парадигма программирования здесь следующая:

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

Рассмотрим пример модуля, скрывающего данные стека. Хорошее решение требует:

Возможным внешним интерфейсом к модулю со стеком может быть:

     //определение интерфейса к модулю
     //стек символов
     char pop();
     void push(char);
     const stack_size=100;

Предположим, что этот интерфейс хранится в файле под названием stack.h, тогда внешние переменные могут быть определены следующим образом (объявление static означает, что переменная является локализованной в этом файле (модуле)):

     #include "stack.h"

     static char v[stack_size]; 
     static char *p=v;  //стек вначале пуст        

     char pop()
     {
      //проверить, не пуст ли стек,
      //и pop
     }

     void push(char)
     {
      //проверить, не переполнен ли стек,
      //и push
     }

Несложно преобразовать стековое представление в связанный список. В любом случае у пользователя нет доступа к представлению данных (потому что V и P определены как static, т.е. локализованы в файле или модуле, в котором объявлены). Такой стек можно использовать следующим образом:

     #include "stack.h"
     void some_function ()
     {
       char c = pop(push ('c'));
       if (c!='c') error ("impossible");
     }

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

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

В одном из Pascal-подобном языке - Modula-2 - пошли несколько дальше. В нем формализация понятия модуля происходит путем определения модуля как фундаментальной конструкции с хорошо определенными декларациями, явным контролем всей совокупности используемых имен (средства импорта/экспорта), механизмом инициализации модуля и множеством общеизвестных допускаемых стилей программирования.

Другими словами, в С имеются средства разделения программы на модули, в то время как Modula-2 поддерживает эту возможность.

Абстракция данных

Подходы в программировании

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

     //stack_id это тип; никаких других подробностей
     //stack или stack_ids здесь доступны:
     class stack_id;

     //организовать стек и вернуть его идентификатор
     stack_id create_stack (int size);
     //когда стек больше не нужен удалить его
     destroy_stack (stack_id);

     void push(stack_id,char);
     char pop(stack_id);

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

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

Например:

     void f()
     {
       stack_id s1;
       stack_id s2;

       sl=create_stack (200);
       //Oops:забыли создать s2

       char c1=pop(s1,push(s1,'a'));
       if (c1!='c')error("impossible");

       char c2=pop(s2,push(s2,'a'));
       if(c2!='c')error("impossible");

       destroy(s2);
       //Oops:забыли разрушить s1
     }

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

Абстрактные типы данных

Подходы в программировании

В таких языках, как Ada, Clu, С++, проблема абстрактных типов данных (АТД) решается путем предоставления пользователю возможностей определения типов, которые ведут себя почти таким же образом, как встроенные типы. Типы такого вида часто называются абстрактными типами данных, хотя я предпочитаю называть их типами, определенными пользователем 1 .

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

Здесь парадигма программирования следующая:

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

     class complex{
        double re,im;

public:
     // инициализация complex
        complex(double r, double i){ re=r; im=i; }

     // преобразование из double в complex
        complex(double r){ re=r; im=0; }
     
        friend complex operator+ (complex,complex);
        friend complex operator- (complex,complex);
        friend complex operator* (complex,complex);
        friend complex operator/ (complex,complex);

     // унарный минус
        friend complex operator- (complex);
     
     //...
     }

Объявление класса(АТД) комплексное число классифицирует представление комплексного числа (даннные) и множества операций над такими числами (функции). Представление является закрытым, т.е. re и im доступны только функциям, специфицированным в объявлении класса. Такие функции можно определить так:

     complex operator+(complex a1, complex a2)
     {
      return complex (a1.re+a2.re, a1.im+a2.im);
     }

и использовать так:

     complex a=2.3;
     complex b=1/a;
     complex c=a+b*complex(1.2, 3);
     //...
     с=-(а/в)+2;

Лучше большую часть модулей (но не все) представлять как АТД. В случаях, когда программист предпочитает использовать модульное представление даже при доступных средствах определения типов, он сможет определить только единичный объект требуемого типа. Как альтернатива, язык может обеспечивать концепцию модульности в дополнение или в противовес концепции класса.

Проблемы

Подходы в программировании

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

Рассмотрим определяемый для использования в графике тип shape (форма). Предположим на время, что система должна поддерживать окружности, треугольники и квадраты. Допустим также, что у вас есть ряд классов:

     class point{/*...*/};    //point - точка
     class color{/*...*/};    //color - цвет

Можно определить тип форма - shape - как:

     //сircle -круг,
     //triangle -треугольник,
     //squre - квадрат
     enum кind{circle,triangle,sguare};   

     class shape
     {
           point center;
           color col;
           kind  k;
     public:
           point where() {returu center;}
           void  move(point to){ cenre=to; draw();}
           void  draw();
           void  rotate(int);
     };

Тип kind используется в таких операциях, как draw() - рисовать и rotate() - вращать, для определения формы, с которой они имеют дело (в языках типа Pascal можно использовать иную запись с тегом k-tag k).

Функцию draw() можно определить так:

     void shape::draw()
     {
      switch(k)
      {
       case circle:
         //рисовать круг
           break;
       case triangle:
        //рисовать треугольник
          break;
       case square:
        //рисовать квадрат
      }
     }

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

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

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

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


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

Содержание

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

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

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

     class shape
     {
          point center;
          color col;
          //...
      public:
          point where() {return center;}
          void move(point to) {center=to;draw();}
          virtual void draw();
          virtual void rotate(int);
          //...
     };

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

     //вращать все элементы вектора "v"
     //размера-size на угол-angle
     void rotate-all( shape *v, int size, int angle)
     {
          for (int i=0;i<size;i++)
           v[i].rotate(angle);
     }

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

     class circle: public shape 
     {
          int radius;
     public:
          void draw(){/*...*/};
          //да, пустая функция
          void rotate(int) {}
     };

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

Парадигма программирования такова:

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

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

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

Hurapg (Nygaard)[1] и Kerr(Kerr)[2]объясняют, что такое объектное программирование, не прибегая к специфическим языковым конструкциям; (Carqill)[3] посвятил объектному программированию специальное исследование.

Поддержка абстракции данных

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

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

Инициализация и очистка

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

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

     class vector
     {
           int    sz;
           int    *v;
     public:
           //вызвать функцию init для 
           //инициализации sz и v перед
           //первым использованием вектора
           void init(int size);

           //...
     };

     //объявление переменной
     vector    v;
     //не используйте здесь v
     v.init(10);
     //здесь можно использовать v

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

В случаях, когда конструирование новых типов не тривиально, часто необходимо проводить противоположную операцию очистки объектов после их последнего использования. В С++ функция очистки называется деструктором (destructor). Рассмотрим тип "вектор" (vector).

     class vector
     {
          int    sz;   //количество элементов
          int    *v;   //указатель к целым(inteqers)
     public:
          vector(int); //конструктор
          vector();    //деструктор

          //индекс-оператор
          int    operator[] (int index); 
     };

Конструктор типа "вектор" может распределять память, например, так:

     vector::vector(int s)
     {
        if(s<=0)error("неверна размерность вектора");
        sz=s;
        v=new int[s]; //распределить массив s целых
     }

Деструктор типа "вектор" освобождает память:

     vector::~vector()
     {
         delete v; //освободить память,
                   // выделенную v
     }

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

Присвоение и инициализация

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

Управление конструкцией (деструкцией) объектов оказывается достаточным для многих типов, но не для всех. Иногда необходимо управлять операцией копирования. Рассмотрим класс векторов:

     //проинициализировать
     vector    v1(00);
     vector    v2(v1); //здесь иниц. тоже
     //присвоить
     v1=v2;

Должна быть возможность определять значение инициализации v2 и его присвоение v1. Также должны быть средства, препятствующие подобным операциям копирования; предпочтительнее, чтобы были доступны обе альтернативы. Например:

     class vector {
        int*v;
        int sz;
     public:
        //...
        void operator= (vector z); //присвоение
        vector(vector z);          //инициализация
     };

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

     //проверить разменность и скопировать
     //элементы
     vector::operator= (vector a)
     {
          if(sz != a.sz)
           error("неверная размерность для =");

          for(int i=0;i<sz;i++)
           v[i]=a.v[i];
     }

Операция инициализации должна отличаться от операции присвоения в связи с тем, что присвоение зависит от исходного значения вектора. Например:

     //инициализация вектора 
     //значениями другого вектора
     vector::vector(vector a)
     {
           //та же размерность
           sz=a.sz;      
           //выделить память массиву элементов 
           v=new int[sz];

           //скопировать элементы
           for(int i=0;i<sz;i++)
            v[i]=a.v[i];
     }

С++ конструктор T(T obj)определяет всю процедуру инициализации объектов типа T с помощью значения элементов другого объекта типа T. В дополнение и явной инициализации конструкторы вида T(T obj) используются для обработки передаваемых по значению аргументов и возвращаемых функциями значений.

В С++ присвоению объекта класса T можно воспрепятствовать, объявляя операцию присвоения как личную:

     class T
     {
          //только члены T могут копировать T
          void    operator= (T obj); 
          T(T obj);
      public:
        ...
     };

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

Параметризованные типы

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

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

     //вектор элементов типа Т
     class vector<classT>
     {
          T      *v;
          int    sz;

      public:
          vector(int s)
           {
            if(s<=0)
             error("неверна размерность вектора");

            //распределить память под массив
            v=new T[sz=s]; 
           }

          T      operator[] (int i);
          int    size(){return sz;}
          //...
     };

Теперь можно определять и использовать векторы специфических типов:

     //v1-вектор из 100 целых чисел
     vector<int>     v1(100);

     //v2-вектор из 200 комплексныхчисел
     vector<complex> v2(200);

          v2[i]=complex(v1[x],v1[y]);

В языках Ada и Clu поддерживаются параметризовнные типы. К сожалению, в С++ этого средства нет; приведенные примеры использовали экспериментальные конструкции языка. Когда в программе встречаются параметризованные классы, они подменяются макросами. В случае, если все используемые типы явно определены, на стадии выполнения дополнительные накладные расходы, связанные с использованием классов, не возникают.

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

     cannot define
     vector<non_copy>::operator[](non_copy z):
        type noncopy does not have operator=

Эта технология позволяет таким образом описывать типы, что зависимости атрибутов параметрического типа обрабатываются на уровне индивидуальной операции типа. Например, можно определить операцию sort (сортировка) для вектора. Операция sort может использовать операции <, ==, > над объектами параметрического типа. Возможность определения вектора такого типа, что операция < для него не определена, сохраняется до тех пор, пока операция sort не была действительно использована.

При использовании параметризованных типов проблема состоит в том, что каждая актуализация создает независимый тип. Например тип vector<char> не связан с типом vector<complex>. В идеале должна сохраняться возможность выражать и использовать общие свойства типов, полученных из одного параметризованного типа. Например, и к vector<chav> и к vector<cjmplex> применима функция size(), не зависящая от типа параметра. Оказывается возможным, хотя это и не просто, выводить общность свойств из определения класса векторов с тем, чтобы допустить применение функции size() к любому вектору. И в этом преимущество языка, поддерживающего как параметризацию типов, так и механизм наследования свойств.

Обработка ошибок

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

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

В языках Ada, Algol-68, Clu есть средства стандартизованной обработки ошибок. В С++ таких средств, к сожалению нет. Если возникает необходимость, ошибочные ситуации обрабатываются с использованием указателей к функциям, ошибочных состояний и средств библиотеки языка Си signal и longjmp. Это все является явно неудовлетворительным, потому что даже не может обеспечить стандартную схему обработки ошибок.

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

     class vector
     {
        ...
      except vector-range{
        //определить ошибочную ситуацию,
        //называемую vector-range и специфицировать
        //программу для ее обработки
        error("global:vector range error");
        exit(99);
      }
     };

Вместо обращения к функции обработки ошибок error, vector::operator[]() может генерировать код ошибки:

     T    vector::operator[](int i)
     {
          if(0 < i || sz <= i)
           raise vector_range;

          return v[i];
     }

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

Функция обработки ошибки может быть определена для специального блока программы:

     void    f()
     {
      vector    v(10);
      try{      //здесь обрабатываются ошибки
                //определенной нише локальной
                //функцией обработки ошибок
      //...
      iut i=g(); //g может приводить к ошибке
                //размерности из-за потенциально
                //возможной ошибки размерности
      v[i]=7;    //вектора
      }

      except{
        vector::vector_range:
        error("f():ошибка ранга вектора");
        return
      }
                 //здесь ошибки обрабатываются
                 //глобальной функцией обработки
                 //ошибок, определенной в классе vector
      int i=g();  //g может приводить к ошибке
                //размерности из-за потенциально
                //возможной ошибки размерности
      v[i]=7;
     }

Существует много способов определения ошибочных ситуаций и поведения функций обработки ошибок. Кратко изложенные здесь средства похожи на соответствующие возможности языка Modula-2+. Этот стиль обработки ошибок можно реализовать таким способом, чтобы программа обработки ошибок не выполнялась до тех пор, пока не возникнет соответствующая ошибочная ситуация (исключая, возможно, стадию начальной инициализации). Воспользоваться такими средствами можно в любых реализациях Cu, используя функции setjmp() и longjmp() (см.руководство по библиотеке Си для вашей операционной системы).

Можно ли описанным способом переложить всю обработку ошибочных ситуаций на такой язык, как С++? К сожалению, нет. Препятствие состоит в том, что когда случается ошибка, нельзя освобождать стек вызовов до точки обращения к функции обработки ошибок. Чтобы это правильно сделать в С++ используются вызываемые деструкторы, определенные в используемых программах. Это не отмечено в С-функции longjmp() и в общем случае не может быть обеспечено пользователем.

Преобразования типов

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

Определяемые пользователем преобразования, например, плавающих чисел в комплексные с помощью конструктора complex(double), оказались неожиданно полезными в С++. Такие преобразования могут применяться явно или неявно с помощью компилятора там, где это необходимо и не приводит к противоречиям:

     complex a=complex(1);
     complex b=1; //неявно:
                  //1->complex(1)
     a = b + complex (2);
     a = b + 2;   //неявно:
                  //2->complex(2)

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

Один способ использования преобразований оказался особенно полезным при организации программ:

     complex a=2;
     complex b=a+2; //интерпретируется как
                    //оператор + (а,complex(2))
     b = 2 + a;     //интерпретируется как
                    //оператор + (complex(2),a)

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

Это контрастирует с "чистой" объектно-ориентированной системой программирования, где операции интерпретируются следующим образом:

     a + 2;    //a.operator + (2)
     2 + a;    //2.operator + (a)

что приводит к необходимости модифицировать класс целых чисел, чтобы допустить возможность выражения 2 + а.

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

Итераторы(в этом переводе повторители)

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

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

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

Существует несколько стилей итерирования. Предпочитаемый мной опирается на перегрузку оператора вызова функции ():

     class vector_iterator
     {
        vector  v;
        int     i;
     public:
        vector_iterator(vector r){ i=0; v=r; }
        int    operator() ()
         { return i<v.size()? v.elem(i++): 0; }
     };

Теперь можно объявлять тип vector_iterator и использовать его для векторов:

     vector v(sz);
     vector_iterator next(v);
     int    i;
     while ( i=next() ) print(i);

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

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

     class vector
     {
          int    *v;
          int    sz;
          int    current;
      public:
          //...
          int next()
           { return (current++<sz)?v[current]:0; }

          int prev()
           { return (0<-current)?v[current]:0; }
     };

Тогда интерацию можно организовать так:

     vector v(sz);
     int i;
     while ( i=v.next() )print(i);

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

Особенности реализации

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

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

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

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

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

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

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

Механизм вызова

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

Ключевым средством языка для поддержки объектно-ориентированного программирования является механизм, регулирующий подключение правильных функций для объекта. Например, при данном указателе Р, как реализовать обращение р->f(arg)? Существуют различные способы решения.

В таких языках, как С++ и Simula, где широко используется статическая прверка типов, система поддержки типов может использовать различные механизмы вызова. В С++ есть две альтернативы:

  1. Обычное обращение к функции
    нужная функция определяется во время компиляции (просмотром таблицы символов компилятора); обращение к ней происходит с помощью обычного механизма вызова с использованием аргумента, идентифицирующего объект, для которого нужна вызываемая функция. В случаях, когда стандартный механизм вызова не эффективен в достаточной мере, программист может указать, что функцию следует прямо встраивать в код программы в месте обращения к ней. Это позволяет достигнуть эффективности макрорасширений, не нарушая стандартной семантики обращения к функциям. Такая оптимизация равно годится и в случае абстракции данных.
  2. Виртуальное обращение к функции
    вызов функции зависит от типа объекта, который обычно можно определить только во время выполнения программы. Типичной является ситуация, когда указатель Р связан с некоторым базовым классом В, а объект относится к некоторому производному классу D. Механизм вызова должен "заглянуть" в объект, найти помещенную туда компилятором информацию и определить, к какой функции класса следует обратиться. Как только соответствующая функция, скажем D::F, найдена, для обращения к ней использутся выше описанный механизм. На стадии компиляции имя F преобразуется в индекс к таблице указателей к функциям. Такой виртуальный механизм вызова может быть сделан таким же эффективным, как и обычный. В стандартной реализации языка С++, только пять ссылок на дополнительную память. В случаях, когда тип объекта можно определить во время компиляции, можно исключить даже эти накладные расходы и использовать средства встраивания кода функции в код программы. Такие случаи являются достаточно общими и важными.

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

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

Проверка типов

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

Приведенный пример с формами показал мощь виртуальных функций. Что еще предоставляет программисту механизм обращения к функции? Он позволяет использовать любую функцию для любого объекта.

Эта возможность позволяет разработчику библиотеки функций общего назначения перенести ответственность по обработке типов на плечи пользователя. Естественно это облегчает проектирование библиотеки. Например:

     class stack 
     {  
         // предположим класс any
         // содержит функцию next
         any   *v;

         void push(any p)
         {
             p->next = v
             v = p;
         }

         any* pop()
         {
             if(v==0)return error_obj;
             ani *r=v;
             v=v->next;
             return r;
         }
     };

Теперь пользователь должен избегать смешения типов, например:

     Stack<any*> cs;

     //Saab 900 это автомобиль
     cs.push(new car(Saab 900) );
     //Saab 37B это самолет
     cs.push(new plane(Saab 37B) );

     plane *p=(plane*)cs.pop();
     //взлетать
     p->takeoff();

     p=(plane*)cs.pop();
     //ошибка на стадии выполнения;
     //автомобиль не имеет функции takeoff()
     p->takeoff();

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

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

     stack<plane*> cs;
     cs.push(new Saab 900);
        //Ошибка на стадии компиляции: смешение типов;
        //используется car*, ожидается plane*
     cs.push (new Saab 37B);
     plane *p=cs.pop();
     p->takeoff(); //ошибка: Saab 37B это самолет

     p=cs.pop();
     p->takeoff();

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

Например, классы в языках Simula или С++ специфицируют фиксированный интерфейс к соответствующему множеству объектов (или любому производному классу), в то время как в языке Smalltalk классы специфицируют базовое множество операций для объектов (или подкласса). Другими словами, классы в языке Smalltalk определяют минимальные спецификации, а пользователь может пытаться использовать не специфицированные операции. В языке же С++ класс представляет собой точную спецификацию, и компилятор допускает только операции, специфицированные в определении класса.

Механизм наследования

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

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

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

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

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

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

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

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

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

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

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

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

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

     class my-displayed-task
     :public displayed, public task{
        //мое"хозяйство"
     };
     class my-task
     :public task{//мое "хозяйство"
                  //не выводимое на дисплей
     };
     class my-displayed
     :public displayed{//не задача
     };

В случае единичного наследования программисту открыты только две из этих трех возможностей. Это ведет к дублированию программного кода или потере гибкости, а чаще всего-и того, и другого. В С++ этот пример может быть реализован без значительных накладных расходов (по времени и памяти), сравнимых с единичным наследованием и без отказа от статической проверки типов [9].

Неопределенности обнаруживаются во время компиляции:

     class A {public:f();...};
     class B {public:f();...};
     class C:public A, public B {...};

     void g(){
          c*p;
          p->f();///ошибка:двусмысленность

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

В С++ типичным путем разрешения неопределенности является добавление функции:

     class C:public A, public B{
     public:
        f()
        {
             //"хозяйство" класса С
            A::f();
            B::f();
         }
         ...
         }

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

     class W {...};
     class B window //окно с границей
     :public virtual W
     {...};
     class M window //окно с меню
     :public virtual W
     {...};
     class BMW       //окно с границей и меню
     :public B window, public M window
     {...}

Здесь единичный подобъект "окно" разделяется подобъектами В window и M window класса BMW.В диалектике Lisp для облегчения программирования с использованием таких сложных иерархий классов применяется комбинирование служебных утилит. В С++ в этом нет необходимости.

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

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

Рассмотрим элемент класса (данные или функцию), которые следует защитить от неавторизованного доступа. Какие решения приемлемы, чтобы ограничить доступ к этому элементу?

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

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

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

Рассмотрим пример, который демонстриует некоторые из возможных путей инкансуляции в С++: