Физическое и логическое постоянство



 

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

 

time.hpp

 

#ifndef _TIME_HPP_

#define _TIME_HPP_

 

/*****************************************************************************/

 

class Time

{

 

/*-----------------------------------------------------------------*/

 

public:

 

/*-----------------------------------------------------------------*/

 

// Объявление конструктора

Time ( int _hours, int _minutes );

 

// Методы доступа к часам и минутам

int GetHours () const;

int GetMinutes () const;

 

// Метод получения строкового представления

const char * ToString () const;

 

/*-----------------------------------------------------------------*/

 

private:

 

/*-----------------------------------------------------------------*/

 

// Метод проверки инвариантов

bool IsValid () const;

 

/*-----------------------------------------------------------------*/

 

// Часы и минуты

int m_hours, m_minutes;

 

/*-----------------------------------------------------------------*/

 

};

 

/*****************************************************************************/

 

// Реализация метода доступа к часам

inline int Time::GetHours () const

{

return m_hours;

}

 

/*****************************************************************************/

 

// Реализация метода доступа к минутам

inline int Time::GetMinutes () const

{

return m_minutes;

}

 

/*****************************************************************************/

 

#endif // _TIME_HPP_

 

 

time.cpp

 

/*****************************************************************************/

 

#include "time.hpp"

#include <stdexcept>

 

/*****************************************************************************/

 

// Реализация конструктора

Time::Time ( int _hours, int _minutes )

: m_hours( _hours ), m_minutes( _minutes )

{

// Проверка инвариантов

if ( ! IsValid() )

   // Объект в некорректном состоянии, генерируем исключение

   throw std::logic_error( "Error: time is not valid!" );

}

 

/*****************************************************************************/

 

// Реализация метода проверки инвариантов

bool Time::IsValid () const

{

// Часы должны находиться в интервале [0:23]

if ( m_hours < 0 || m_hours > 23 )

   return false;

 

// Минуты должны находиться в интервале [0:59]

return m_minutes >= 0 && m_minutes <= 59;

}

 

/*****************************************************************************/

 

// Реализация метода получения строкового представления

const char * Time::ToString () const

{

// Статический локальный буфер (4 цифры, двоеточие и нулевой завершитель)

static char tempBuf[ 6 ];

 

// Формируем результирующую строку в указанном буфере

sprintf( tempBuf, "%d:%d", m_hours, m_minutes );

 

// Возвращаем адрес начала буфера

return tempBuf;

}

 

/*****************************************************************************/

 

Теперь воспользуемся разработанной функцией и выведем пару объектов-времен на экран:

 

test.cpp

 

#include "time.hpp"

#include <iostream>

 

int main ()

{

Time t1( 17, 15 );

Time t2( 23, 10 );

const char * str1 = t1.ToString();

const char * str2 = t2.ToString();

std::cout << str1 << ' ' << str2 << std::endl;

}

 

 

Однако, вывод на экране не соответствует ожиданиям на первый взгляд:

 

 

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

 

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

 

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

 

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

 

class Time

{

private:

 

// ...

 

// Буфер для строкового представления. Можно изменять даже в const-методах

mutable char m_tempBuf[ 6];

};

 

 

const char * Time::ToString () const

{

// Размещаем результирующую строку в мутирующем буфере внутри объекта

sprintf( m_tempBuf, "%d:%d", m_hours, m_minutes );

return tempBuf;

}

 

Не внося ни одного изменения в прежнюю тестовую программу, получим ожидаемый результат:

 

 

Представляется возможным повысить производительность операции ToString для класса Time, если избежать переформирования строкового представления при отсутствии изменения состояния данных о часах и минутах. Для этого понадобится еще одна “мутирующая” переменная-член, означающая, что объект не менял своего состояния, соответственно, буфер уже содержит нужные символы:

 

class Time

{

public:

 

// ...

 

// Метод перехода к следующей минуте

void NextMinute ();

 

private:

 

// ...

 

// Буфер для строкового представления. Можно изменять даже в const-методах

mutable char m_tempBuf[ 10 ];

 

// Флаг валидности содержимого буфера

mutable bool m_bufferUpToDate;

};

 

/*****************************************************************************/

 

// Реализация конструктора

Time::Time ( int _hours, int _minutes )

: m_hours( _hours ), m_minutes( _minutes ),

   m_bufferUpToDate( false ) // <--- буфер изначально в невалидном состоянии

{

// Проверка инвариантов

if ( ! IsValid() )

   // Объект в некорректном состоянии, генерируем исключение

   throw std::logic_error( "Error: time is not valid!" );

}

 

/*****************************************************************************/

 

// Реализация метода перехода к следующей минуте

void Time::NextMinute ()

{

if ( m_minutes == 59 )

{

   m_minutes = 0;

   if ( m_hours == 23 )

       m_hours = 0;

   else

       ++ m_hours;

}

Else

   ++ m_minutes;

 

// Изменение состояния объекта делает буфер неактуальным

m_bufferUpToDate = false;

}

 

/*****************************************************************************/

 

// Реализация метода получения строкового представления

const char * Time::ToString () const

{

// Переформируем буфер только если он в неактуальном состоянии

if ( ! m_bufferUpToDate )

{

   // Размещаем результирующую строку в мутирующем буфере внутри объекта

   sprintf( m_tempBuf, "%d:%d", m_hours, m_minutes );

 

   // Буфер теперь актуален

   m_bufferUpToDate = true;

}

return m_tempBuf;

}

 

/*****************************************************************************/

 

Если таких “мутирующих” полей в классе появляется много, это скорее всего свидетельствует о необходимости выноса части состояния объекта в другой вспомогательный объект, отдельно отвечающий за физическую часть:

 

class Time

{

private:

 

// …

 

// Вспомогательный объект-буфер

struct StringRepr

{

   char m_tempBuf[ 6 ];

   bool m_isUpToDate = false;

 

} * m_pStringRepr;

 

int m_hours, m_minutes;

};

 

// Реализация конструктора

Time::Time ( int _hours, int _minutes )

: m_hours( _hours ), m_minutes( _minutes ),

   m_pStringRepr( new StringRepr() )

{

// Проверка инвариантов

if ( ! IsValid() )

   // Объект в некорректном состоянии, генерируем исключение

    throw std::logic_error( "Error: time is not valid!" );

}

 

// Реализация метода перехода к следующей минуте

void Time::NextMinute ()

{

if ( m_minutes == 59 )

{

   m_minutes = 0;

   if ( m_hours == 23 )

       m_hours = 0;

   else

       ++ m_hours;

}

Else

   ++ m_minutes;

 

// Изменение состояния объекта делает буфер неактуальным

m_pStringRepr->m_isUpToDate = false;

}

 

// Реализация метода получения строкового представления

const char * Time::ToString () const

{

// Переформируем буфер только если он в неактуальном состоянии

if ( ! m_pStringRepr->m_isUpToDate )

{

   // Размещаем результирующую строку в мутирующем буфере внутри объекта

   sprintf( m_pStringRepr->m_tempBuf, "%d:%d", m_hours, m_minutes );

 

   // Буфер теперь актуален

   m_pStringRepr->m_isUpToDate = true;

}

return m_pStringRepr->m_tempBuf;

}

 

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

 

Std::string

 

Многие программисты, предпочитающие другие языки программирования, часто нарекают на сложность работы со строками в С++. В более высокоуровневых языках, таких как Java, C#, Python, строки являются встроенными типами, и манипулировать ими так же просто как и числами. Большинство программ так или иначе манипулируют со строковыми данными, и такие действия, как создание, определение длины, копирование, сравнение, поиск и замена фрагментов, конкатенация (присоединение одной строки в конец другой) - являются постоянно востребованными операциями. 

 

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

 

Принципы объектно-ориентированного программирования, предоставляемые С++, позволяют эффективно сочетать потребности программистов в удобстве работы со строками вместе с производительностью низкоуровневого представления. В частности, стандартная библиотека предлагает для нужд работы со строками мощный готовый класс std::string, доступный в заголовочном файле <string>. Его интерфейс хорошо продуман, и использование таких строк практически не отличается от манипулирования значениями встроенных типов:

 

#include <string>

#include <iostream>

 

int main ()

{

// Объявляем два объекта-строки

std::string s1, s2;

 

// Вводим строки из входного потока

std::cout << "Please enter two strings separated by spaces: ";

std::cin >> s1 >> s2;

 

// Выводим длину строк

std::cout << "Lengths of strings are: "
         << s1.length() << " and " << s2.length() << std::endl;

 

// Сравниваем строки

if ( s1 == s2 )

   std::cout << "Strings are equal" << std::endl;

else if ( s1 < s2 )

   std::cout << "First string is lexicographically smaller" << std::endl;

Else

   std::cout << "Second string is lexicographically smaller" << std::endl;

 

// Конкатенируем строки

std::cout << ( s1 + s2 ) << std::endl;

 

// Перебираем строки посимвольно

for ( char c : s1 )

   std::cout << c << ' ';

std::cout << std::endl;

}

 

В результате получаем следующий вывод программы:

 

 

Как видно из приведенного примера программного кода, используя класс std::string можно не заботиться о низкоуровневом внутреннем представлении строки. Этот класс определяет необходимые конструкторы, средства для копирования и перемещения, деструктор. Типичная реализация оптимизирует хранение символов таким образом, что для строк небольшого размера (до 16 символов), кои встречаются в большинстве программ наиболее часто, вообще не происходит динамического выделения памяти. Вместо этого реализация хранит символы в статическом буфере до тех пор, пока строка не увеличивается в размере до достаточно длинной. При дальнейшем росте, std::string выделяет динамическую память подобно векторам из дисциплины “Структуры и алгоритмы обработки данных”.

 

В связи с этим, объекты std::string можно свободно присваивать, передавать в качестве аргументов функций, возвращать из функций как результат, создавать временные объекты для формирования строк из фрагментов. Учитывая поддержку семантики перемещения в С++’11 в современных компиляторах, типичные строки std::string имеет смысл передавать по значению.

 

Класс std::string также интенсивно использует механизмы перегрузки операторов, в частности:

● оператор индексной выборки [] для обращения к конкретным символам строки;

● операторы +, += для конкатенирования строк;

● операторы сравнения (==, !=, <, <=, >, >=);

● операторы <<, >> для ввода вывода через потоки (консоль, файлы).

 

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

 

Класс std::string поддерживает идиому интервального цикла for, возвращая указатели на внутреннее представление буфера, используя методы begin() и end(). Это позволяет обрабатывать содержимое строк посимвольно в более простой форме, чем через оператор индексной выборки.

 

Также, класс std::string предлагает широкий набор вспомогательных методов, полезных для типичных задач со строками. Среди наиболее часто используемых следующие методы:

● size, length - методы определения длины;

● empty - метод определения пустоты строки;

● reserve, capacity - средства резервирования места для хранения символов заранее;

● clear - метод очистки строки;

● insert/erase - вставка/удаление фрагментов;

● replace - замена фрагментов на другие;

● find, rfind, find_first_of, find_last_of - поиск фрагментов;

● substr - получение подстроки;

● с_str - преобразование к строке в стиле языка С (указатель на const char * ).

 

Изучить детали использования данных библиотечных средств, а также весь набор доступных функций класса std::string - можно во множестве справочных источников по языку С++ - в литературе или в сети Internet (например, на популярном сайте http://www.cplusplus.com).

 

В дальнейшем в данном курсе для реализации изменяемых строк всегда будут использоваться объекты std::string, а строки в стиле языка С будут применяться только для неизменяемых строк.

 

Enum class

 

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

 

enum StreamState

{

STATE_OK,

STATE_END_OF_FILE,

STATE_INPUT_FAILURE,

STATE_SYSTEM_ERROR,

};

 

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

 

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

 

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

 

int x = STATE_END_OF_FILE; // Неявное преобразование к int, но ожидаемый эффект

 

void f ( StreamState s )

{

if ( s == 1 ) // Неявное преобразование к int, имеем в виду STATE_END_OF_FILE

{

   // ...

}

else if ( s == 5 ) // Неявное преобразование к int, при этом, НЕКОРРЕКТНОЕ!!!

{

   // ...

}

}

 

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

 

enum class StreamState

{

OK,

END_OF_FILE,

INPUT_FAILURE,

SYSTEM_ERROR,

};

 

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

 

StreamState s = StreamState::EOF;

 

Второе отличие - отсутствие неявных преобразований к числовым типам:

 

int x = StreamState::END_OF_FILE; // Ошибка: преобразование к int запрещено

 

void f ( StreamState s )

{

if ( s == 1 ) // Ошибка: преобразование к int запрещено

{

   // ...

}

else if ( s == 5 ) // Ошибка: преобразование к int запрещено

{

   // ...

}

}

 

Такой более строгий подход позволяет обойти ряд типичных трудноуловимых ошибок.

 

Явное преобразование по-прежнему доступно:

 

int x = ( int ) StreamState::END_OF_FILE; // ОК, явное преобразование

 

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

 

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

 

#include <iostream>

 

enum class StreamState1        { OK, END_OF_FILE, INPUT_FAILURE, SYSTEM_ERROR };

enum class StreamState2 : char { OK, END_OF_FILE, INPUT_FAILURE, SYSTEM_ERROR };

 

int main ()

{

std::cout << sizeof( StreamState1 ) << ‘ ‘

         << sizeof( StreamState2 ) << std::endl;

}

 

Запуск данного примера дает следующий ожидаемый результат:

 

 

Указатели на члены

 

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

 

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

 

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

 

struct Point

{

double m_x;

double m_y;

};

 

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

 

// Определение указателя на переменную-член структуры Point типа double

double Point::* pCoordinate = ( какое-либо условие ) ?

& Point::m_x   // В этой ветке инициализируем адресом поля m_x

: & Point::m_y ; // А в этой ветке — адресом поля m_y

 

// Модифицируем одно из полей, используя объект и указатель на переменную-член

Point p;

( p .* pCoordinate ) = 5;

 

// Читаем одно из полей, используя указатель на объект и указатель на переменную-член

Point * ptr = & p;

std::cout << ( ptr ->* pCoordinate );

 

Разумеется, нельзя взять указатель на член, если не совпадают типы данных указателя и поля.

 

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

 

class Point

{

double m_x;

double m_y;

 

public:

 

Point ( double _x, double _y );

 

double GetX () const { return m_x; }

double GetY () const { return m_y; }

 

void SetX ( double _x ) { m_x = _x; }

void SetY ( double _y ) { m_y = _y; }

};

 

Объявим два различных указателя на функции-член для соответствующих совместимых по интерфейсу пар методов GetX/GetY и SetX/SetY. Сделаем выбор в пользу конкретной координаты по некоторому условию, и воспользуемся объектом-точкой вместе со сформированными указателями для увеличения координаты на значение 2.5, а затем восстановим прежнее значение через указатель на объект-точку:

 

int main ()

{

// Указатель на метод из Point, возвращает double,
// ничего не принимает, не меняет объект

double ( Point ::* pGetMethod ) () const;

 

// Указатель на метод из Point, не возвращает,
// принимает аргумент double, меняет объект

void ( Point ::* pSetMethod ) ( double );

 

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

// Для этого присваиваем указателям на методы адреса соответствующих методов

if ( некоторое условие )

{

   pGetMethod = & Point::GetX;

   pSetMethod = & Point::SetX;

}

Else

{

   pGetMethod = & Point::GetY;

   pSetMethod = & Point::SetY;

}

 

// Создаем объект-точку

Point p( 3.5, 4.5 );

 

// Извлекаем текущую координату через объект p и указатель на метод

double currentCoordinate = ( p .* pGetMethod )();

 

// Обновляем текущую координату через объект p и указатель на метод

( p .* pSetMethod ) ( currentCoordinate + 2.5 );

 

// Берем адрес объекта

Point * ptr = & p;

 

// Извлекаем текущую координату через указатель на объект ptr и указатель на метод

currentCoordinate = ( ptr ->* pGetMethod ) ();

 

// Обновляем текущую координату через указатель на объект ptr и указатель на метод

( ptr ->* pSetMethod )( currentCoordinate – 2.5 );

}

 

 

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

 

class A

{

public:

   

// Статическая переменная-член

static int ms_data;

 

// Статическая функция-член

static int GetData () { return ms_data; }

};

 

// Определение статического члена

int A::ms_data;

 

int main ()

{

// Использование указателя на статическую переменную-член класса

const int * pData = & A::ms_data;

std::cout << * pData;

 

// Использование указателя на статическую функцию-член класса

int ( * pStaticMethod )() = & A::GetData;

std::cout << ( * pStaticMethod )();

}

 

Полные примеры из лекции

 

https://github.com/zaychenko-sergei/oop-samples/tree/master/lec5

 

Выводы

 

В ходе данной лекции был введен ряд дополнительных конструкций и приемов для разработки более совершенных классов. Были подробно рассмотрены статические члены классов с примерами их применения для актуальных задач. Продемонстрирован ряд конструкций утилитарного характера, таких как ключевое слово mutable и указатели на члены классов. Также был применен часто используемый класс из стандартной библиотеки для работы со строками - std::string.

 


Дата добавления: 2021-01-21; просмотров: 51; Мы поможем в написании вашей работы!

Поделиться с друзьями:






Мы поможем в написании ваших работ!