Сколько плюсов у C++?

AvaLANche (avalanche@real.xakep.ru)

Обзор возможностей языка

Уже долгое время не прекращаются споры, что лучше: Delphi или C/C++/Visual C++. Причем в большинстве случаев сравниваются две принципиально разные вещи. Ведь до седьмой версии имя Delphi носила лишь среда разработки, а язык ее компилятора был Object Pascal (в Delphi7 борландовцы решили устранить такое упущение, и теперь и язык называется Дельфи). То же самое и с Visual C++: эта IDE "накручена" на Microsoft C/C++ Compiler (cl.exe). Поэтому корректным было бы сопоставление Delphi и Visual Studio или объектного паскаля и C++. Почему именно "си-плюс-плюс", а не C? Да потому что C - процедурно-ориентированный язык "среднего уровня", а Object Pascal - высокоуровневый, с поддержкой ООП и абстракций, т.е. это совсем разные вещи. Такая путаница в понятиях заставляет многих думать, что и C, и C++ - почти одно и то же, а ведь это совсем разные языки. Не будем погружаться в дебри Си: тут все довольно понятно, посмотрим лучше, что за зверь C++.

Что такое C++?

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

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

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

Процедуры и функции

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

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

Здесь C++ очень похож на C. Те же инструкции ветвления и циклы, такое же объявление переменных (правда, возможное в любом месте программы), указателей и массивов, множество встроенных типов и т.д. Хотя нововведений тоже немало: ссылки (&), операторы ввода-вывода (>> И <<), операторы для работы с памятью (new и delete), встроенный тип bool и т.д. Стоит упомянуть и обработку исключений. Вот пример функции, которая реализует вежливое, но настойчивое приглашение пользователю выйти :) ("//" - открывает комментарий до конца строки).

bool quit ()

{

char ans;

// сюда мы сохраним ответ

for (int tries = 0; tries <= 5; tries++) {

// спрашивать будем в цикле

cout << "Вы действительно хотите выйти (y/n)? ";

// cout - стандартный поток вывода

cin >> ans;

// cin - стандартный поток ввода

switch (ans) {

case 'y':

return true; // bool может быть только true

case 'n':

cout "А зря!\n";

return false; // или false

default:

cout "Повторяю вопрос:\n";

}

}

cout "Все равно выходим!\n";

return false;

}

Модули и пространства имен

Процедурное программирование - основа основ, его стали применять в первых программах для первых ЭВМ. Но ничто не стоит на месте: сложность программ росла, и со временем важным вопросом стала организация данных. Так появились модули - набор процедур вместе с данными, которые они обрабатывают. Стал актуален принцип сокрытия: "организуй код в модулях так, чтобы скрыть в них данные".

На первый взгляд не совсем понятно, что и зачем нужно скрывать. Ответ прост: пользователю функций (хотя это такой же программер, как и их разработчик, назовем его так) не интересно, как они работают, для него главное, чтобы они действительно работали. Поэтому разработчик предоставляет пользователю некий интерфейс (пользовательский) - все, что необходимо для вызова данного набора функций (модуля). Реализация же этих функций не видна пользователю - она скрыта. По этому принципу построено большинство библиотек (например, WinAPI, где код функций находится в системных dll'ках, а программисты знают о них из заголовочных файлов типа windows.h).

На самом деле модульное программирование не новость и для С-кодеров, но они вынуждены обходиться простой раздельной компиляцией (несколько .c-файлов) и заголовочными .h-файлами. Все объявленные переменные в хидерных файлах оставались по-прежнему глобальными, и к ним можно было легко обратиться из любого места программы. В С++ появилась такая полезная вещь, как пространства имен (namespaces). Объявляя пространство имен, ты, по сути, "ограничиваешь область видимости" всему, что находится внутри него. А внутри может находиться любое объявление. Например, у нас есть пространство имен A, содержащее переменную c == 100, и есть глобальная переменная c == 0. Тогда функции f() и g() выведут 100, а h() - 0:

int c = 0;

namespace A {

int c = 100;

void f () { cout << c; }

}

void g () {

cout << A::c;

}

void h () {

cout << c;

}

Здесь A::c означает, что c берется из пространства имен A. Если бы мы захотели обратиться к глобальной с из функции f (), нам пришлось бы использовать квалификатор глобального namespace'а:

void f () { cout << ::c; }

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

// файл "mystring.h"

namespace MyString { // интерфейс

bool assign (char*); // присвоение значения

int length (); // возвращает длину строки

char* value (); // возвращает значение

}

Получился довольно примитивный модуль :). Теперь посмотрим на его реализацию.

// файл "mystring.с"

#include <string.h>

#include "mystring.h"

namespace MyString { // реализация

const int max_size = 1000; // максимальный размер

int len = 0; // длина

char v[max_size]; // массив символов

}

bool MyString::assign (char* str) {

if (strlen (str) > max_size - 1)

return false;

if (!strcpy (v, str))

return false;

else

return true;

}

int MyString::length () {

return len;

}

char* MyString::value () {

return v;

}

Теперь пользователю достаточно заинклудить mystring.h, и можно пользоваться нашей строкой:

// файл "user.c"

#include "mystring.h"

void f ()

{

if (!MyString::assing ("Yo!"))

cout << "Ошибка!";

else

cout << MyString::value ();

}

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

Используя модуль, описанный выше, ты в какой-то момент столкнешься с проблемой реализации нескольких таких строк. Действительно, трудно представить ситуацию, где достаточно одной подобной строки. Результатом долгих и тяжелых экспериментов над нашим модулем-строкой станет некое подобие типа данных, "псевдо тип" строка. Как это чудо сделать - описывать не буду, потому что такое решение проблемы далеко от идеала. На этот случай C++ припас свое решение - возможность определения типов, которые ведут себя почти как встроенные. Такие типы называются абстрактными или типами, определяемыми пользователем (пользовательскими). Просвещенные товарищи, знакомые с ООП, думаю, уже поняли, о чем речь.

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

class MyString {

int length; // длина

char* v; // массив символов

public:

// создает строку из C-строки cstr

MyString (char* cstr);

// создает пустую строку по умолчанию - нулевой длины

MyString ();

MyString operator+ (MyString); // конкатенация

bool operator== (MyString); // проверка на равенство

// и так далее...

};

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

bool MyString::operator== (MyString mystr)

{

return (strcmp (v, mystr.v) == 0);

}

Функция-член, имеющая то же название, что и класс, называется конструктором. Конструкторы помогают по-разному инициализировать объекты класса - конкретные переменные. Обычно в них выделяется необходимая память, инициализируются переменные и т.п. В MyString, как видно из листинга, два конструктора: один по умолчанию, другой преобразует C-строку в "нашенскую". Пользоваться классом MyString можно так:

MyString mystr; // пустая строка

MyString mystr2 ("C++ рулит");

mystr2 = mystr + MyString ("C тоже ничего");

mystr = mystr2; // теперь обе строки равны

Удобно? Вполне! Пользовательские типы предоставляют огромные возможности и сильно упрощают нелегкий труд программиста при решении самых разных задач, ведь операции над их объектами ничем не отличаются от операций над переменными встроенных типов (int, char и т.д.). Типы, подобные MyString, принято называть конкретными типами.

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

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

class MyString {

virtual int length () = 0;

virtual char* c_str () = 0;

// и дальше в том же духе...

}

Модификатор "virtual" означает "может быть переопределено в производном классе", а "=0" значит, что эта функция ДОЛЖНА быть переопределена в будущем. Конечно, я привел совсем бредовый пример: идея сделать виртуальной функцию, возвращающую длину строки, может родиться только в воспаленном мозгу :). Но смысл, думаю, ты уловил. Фишка в том, что производными классами можно пользоваться, не зная конкретных деталей их реализации:

void f (MyString& mystr)

{

cout << mystr.c_str ();

}

При этом функция f () проглатывает объект любого класса, производного от нашего полиморфного (т.е. предоставляющего интерфейс для множества других) MyString. Например, BigStr:

class BigStr: public MyString {

int length () { return 100; }

// и так далее...

}

Ориентируемся на объекты

Механизм наследования из предыдущего примера приводит нас еще к одной технике - объектно-ориентированному программированию. Его основы - абстракция данных и иерархия классов. Последняя представляет собой различные проявления множественного наследования. Например, класс A, класс B, производный от A, класс С, производный от B, и класс D, производный от A и B, представляют собой несложную иерархию. Производный (дочерний) класс B наследует все члены базового (родительского) класса A - это главная идея наследования.

Теперь принцип написания программы звучит так: "Реши, какие понадобятся классы, обеспечь полный набор операций над ними и вырази общность через наследование". Последнее - довольно непростая задача. Тому, кто ее решит (на этапе проектирования программы), не придется все переделывать в самый ответственный момент.

Обобщенное программирование

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

template<class T> class Stack {

T* v;

void push (T); // добавляем элемент

T pop (); удаляем элемент

// ...

}

Префикс template<T> делает тип T параметром объявления. Такой стек так же легко использовать, как и обычный:

Stack<int> si;

si.push (24);

Stack<MyString> sm;

sm.push (MyString ("str"));

sm.pop ();

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

Шаблоны широко используются в стандартной библиотеке C++ - в STL (Standart Template Library). STL предоставляет пользователям туеву хучу всяких контейнеров (от строк до очередей с двумя концами), потоков ввода-вывода, универсальных алгоритмов и многое другое. Кроме того, она включает в себя всю стандартную библиотеку C. Вывод - must use. Пользоваться ей настоятельно рекомендую еще и потому, что писали ее не один год, постоянно улучшая и модернизируя. И если вдруг кому-то приспичит написать свой собственный вектор тихим майским вечером (лишь бы стандартный не использовать), вряд ли у него получится даже аналог STL'овского.

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



Опубликовал admin
31 Янв, Суббота 2004г.



Программирование для чайников.