Программирование — страница 32 из 57

Графические функции и данные

“Лучшее — враг хорошего”.

Вольтер (Voltaire)


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

15.1. Введение

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

15.2. Построение простых графиков

Начнем. Рассмотрим примеры того, что мы можем нарисовать и как это реализовать в программе. В частности, посмотрим на классы графического интерфейса: мы видим параболу, горизонтальную и наклонную линии.



На самом деле, поскольку эта глава посвящена графическим функциям, данная горизонтальная линия — это не просто какая-то горизонтальная линия, а график функции, представленной ниже.


double one(double) { return 1; }


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

1
. Поскольку для вычисления результата этот аргумент не нужен, называть его необязательно. Для каждого значения
x
, переданного в качестве аргумента функции
one()
, получаем значение
y
, равное
1
; иначе говоря, эта линия определяется равенством
(x,y)==(x,1)
при всех
x
.

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


double slope(double x) { return x/2; }


Эта функция порождает наклонную линию. Для каждого аргумента

x
получаем значение
y
, равное
x/2
. Иначе говоря,
(x,y)==(x,x/2)
. Эти две линии пересекаются в точке
(2,1)
.

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


double square(double x) { return x*x; }


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

(0,0)
, т.е.
(x,y)==(x,x*x)
. Итак, самая нижняя точка параболы касается наклонной линии в точке
(0,0)
.

Ниже приведен фрагмент кода, который рисует три указанные выше линии.


const int xmax = 600;      // размер окна

const int ymax = 400;


const int x_orig = xmax/2; // точка (0,0) — это центр окна

const int y_orig = ymax/2;

const Point orig(x_orig,y_orig);


const int r_min = –10;     // диапазон [–10:11)

const int r_max = 11;


const int n_points = 400;  // количество точек в диапазоне


const int x_scale = 30;    // масштабные множители

const int y_scale = 30;


Simple_window win(Point(100,100),xmax,ymax,"Function graphing");

Function s(one,r_min,r_max,orig,n_points,x_scale,y_scale);

Function s2(slope,r_min,r_max,orig,n_points,x_scale,y_scale);

Function s3(square,r_min,r_max,orig,n_points,x_scale,y_scale);


win.attach(s);

win.attach(s2);

win.attach(s3);

win.wait_for_button();


Сначала определяем несколько констант, чтобы не перегружать нашу программу “магическими константами”. Затем создаем окно, определяем функции, связываем их с окном и передаем контроль графической системе, которая выполняет реальное рисование на экране.

Все это делается по шаблону, за исключением определений трех объектов класса

Function
:
s
,
s2
и
s3
.


Function s(one,r_min,r_max,orig,n_points,x_scale,y_scale);

Function s2(slope,r_min,r_max,orig,n_points,x_scale,y_scale);

Function s3(square,r_min,r_max,orig,n_points,x_scale,y_scale);


Каждый объект класса

Function
определяет, как их первый аргумент (функция с одним аргументом типа
double
, возвращающая значение типа
double
) будет нарисован в окне. Второй и третий аргументы задают диапазон изменения переменной
x
(аргумента изображаемой функции). Четвертый аргумент (в данном случае
orig
) сообщает объекту класса
Function
, в каком месте окна расположено начало координат
(0,0)
.

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

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

Text
(см. раздел 13.11).


Text ts(Point(100,y_orig–40),"one");

Text ts2(Point(100,y_orig+y_orig/2–20),"x/2");

Text ts3(Point(x_orig–100,20),"x*x");

win.set_label("Function graphing: label functions");

win.wait_for_button();


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



 Тем не менее этот рисунок еще нельзя считать законченным. Мы уже отметили, что наклонная линия

x/2
касается параболы
x*x
в точке
(0,0)
, а график функции one пересекает линию
x/2
в точке
(2,1)
, но это известно лишь нам; для того чтобы это стало очевидно читателям, на рисунке следует нанести оси координат.

Код для построения осей состоит из объявлений двух объектов класса

Axis
(раздел 15.4).


const int xlength = xmax–40; // оси должны быть чуть меньше окна

const int ylength = ymax–40;

Axis x(Axis::x,Point(20,y_orig), xlength,

     xlength/x_scale, "one notch == 1");

Axis y(Axis::y,Point(x_orig, ylength+20),

     ylength, ylength/y_scale, " one notch == 1");



Использование значения

xlength/x_scale
в качестве параметра, задающего количество делений, позволяет использовать целочисленные отметки 1, 2, 3 и т.д. Выбор точки
(0,0)
в качестве начала координат является общепринятым. Если хотите, чтобы начало координат было не в центре, а, как обычно, в левом нижнем углу окна (раздел 15.6), вы легко сможете сделать это. Кроме того, для того чтобы различать оси, можно использовать цвет.


x.set_color(Color::red);

y.set_color(Color::red);


Итак, получаем результат, показанный ниже.



 Такой рисунок вполне приемлем, но по эстетическим причинам стоило бы сдвинуть линии немного вниз. Кроме того, было бы неплохо отодвинуть метки оси x немного влево. Однако мы не будем этого делать, поскольку эстетический вид графика можно обсуждать до бесконечности. Одно из профессиональных качеств программиста заключается в том, чтобы знать, когда остановиться и потратить сэкономленное время на что-нибудь более полезное (например, на изучение новых методов или на сон). Помните: “лучшее — враг хорошего”.

15.3. Класс Function

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

Function
приведено ниже.


struct Function:Shape {

  // параметры функции не хранятся

 Function(Fct f,double r1,double r2,Point orig,

          int count = 100,double xscale = 25,double yscale = 25);

};


Класс

Function
является производным от класса
Shape
. Конструктор класса
Function
генерирует множество отрезков линий и хранит их в членах класса
Shape
. Эти отрезки линий аппроксимируют значения функции
f
. Значения функции
f
вычисляются count раз в точках, равномерно распределенных по интервалу
[r1:r2]
.


Function::Function(Fct f,double r1,double r2,Point xy,

                   int count,double xscale,double yscale)

  // строит график функции f(x) для x из диапазона [r1:r2),

  // используя count отрезков линий;

  // начало координат (0,0) располагается в точке xy

  // координаты x масштабируются множителем xscale

  // координаты y масштабируются множителем yscale

{

  if (r2–r1<=0) error("Неправильный диапазон");

  if (count <=0) error("Отрицательное значение count");

  double dist = (r2–r1)/count;

  double r = r1;

  for (int i = 0; i

    add(Point(xy.x+int(r*xscale),xy.y–int(f(r)*yscale)));

    r += dist;

  }

}


Параметры

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

Обратите внимание на то, что объект класса

Function
не хранит значения, передаваемые его конструктору, поэтому мы не можем впоследствии запросить у функции информацию о том, где находится начало координат, или перерисовать график с другими масштабирующими множителями. Этот объект просто хранит точки (в классе
Shape
) и выводит график на экран. Если бы мы хотели повысить гибкость объекта класса Function после его создания, то должны были бы хранить в нем требуемые значения (см. упр. 2). 

15.3.1. Аргументы по умолчанию

Обратите внимание на способ инициализации аргументов

xscale
и
yscale
конструктора класса Function. Такой способ инициализации называют заданием аргументов по умолчанию (default arguments). Их значения используются тогда, когда при вызове значения аргументов вообще не указываются.


Function s(one,r_min,r_max,orig,n_points,x_scale,y_scale);

Function s2(slope,r_min,r_max,orig,n_points,x_scale); // нет

                                                      // yscale

Function s3(square,r_min,r_max,orig,n_points);  // нет xscale,

                                                // нет yscale

Function s4(sqrt,r_min,r_max,orig); // нет count, нет xscale,

                                    // нет yscale


Этот фрагмент кода эквивалентен следующему:


Function s(one,r_min,r_max,orig,n_points,x_scale,y_scale);

Function s2(slope,r_min,r_max,orig,n_points,x_scale, 25);

Function s3(square,r_min,r_max,orig,n_points,25,25);

Function s4(sqrt,r_min,r_max,orig,100,25,25);


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


struct Function:Shape { // альтернатива аргументам, заданным

                        // по умолчанию

  Function(Fct f,double r1,double r2,Point orig,

  int count, double xscale,double yscale);

  // масштаб переменной y по умолчанию:

  Function(Fct f,double r1,double r2,Point orig,

  int count, double xscale);

  // масштаб переменной x и y:

  Function(Fct f,double r1,double r2,Point orig,int count);

  // значение count по умолчанию и масштаб x и y по умолчанию:

  Function(Fct f,double r1,double r2,Point orig);

};


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


struct Function:Shape {

  Function(Fct f,double r1,double r2,Point orig,

  int count = 100,double xscale,double yscale);  // ошибка

};


Если аргумент имеет значение, заданное по умолчанию, то все последующие аргументы также должны их иметь.


struct Function:Shape {

  Function(Fct f,double r1,double r2,Point orig,

  int count = 100,double xscale=25,double yscale=25);

};


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

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

15.3.2. Новые примеры

Мы добавили еще несколько функций — косинус (

cos
) из стандартной библиотеки и — просто для того, чтобы продемонстрировать, как создать сложную функцию, — косинус с наклоном
x/2
.


double sloping_cos(double x) { return cos(x)+slope(x); }


Результат приведен ниже.



Соответствующий фрагмент кода выглядит так:


Function s4(cos,r_min,r_max,orig,400,20,20);

s4.set_color(Color::blue);

Function s5(sloping_cos, r_min,r_max,orig,400,20,20);

x.label.move(–160,0);

x.notches.set_color(Color::dark_red);


Кроме сложения этих двух функций, мы сместили метку оси x и (просто для иллюстрации) немного изменили цвет шкалы деления.

В заключение построим графики логарифма, экспоненты, синуса и косинуса.


Function f1(log,0.000001,r_max,orig,200,30,30); // ln()

Function f2(sin,r_min,r_max,orig,200,30,30);    // sin()

f2.set_color(Color::blue);

Function f3(cos,r_min,r_max,orig,200,30,30);    // cos()

Function f4(exp,r_min,r_max,orig,200,30,30);    // exp() 


Поскольку значение

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



Вместо приписывания меток этим графикам мы изменили их цвет.

Стандартные математические функции, такие как

cos()
,
sin()
и
sqrt()
, объявлены в стандартном библиотечном заголовке
. Список стандартных математических функций приведен в разделах 24.8 и B.9.2. 

15.4. Оси

Для представления данных мы используем класс

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


struct Axis:Shape {

  enum Orientation { x, y, z };

  Axis(Orientation d, Point xy, int length,

    int number_of_notches=0, string label = "");

  void draw_lines() const;

  void move(int dx, int dy);

  void set_color(Color c);

  Text label;

  Lines notches;

};


Объекты

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

Конструктор класса

Axis
размещает линии и добавляет на них деления, если значение
number_ of_notches
больше нуля.


Axis::Axis(Orientation d, Point xy, int length, int n, string lab)

     :label(Point(0,0),lab)

{

  if (length<0) error("bad axis length");

  switch (d){

    case Axis::x:

    {

      Shape::add(xy); // линия оси

      Shape::add(Point(xy.x+length,xy.y));

      if (0

        int dist = length/n;

        int x = xy.x+dist;

        for (int i = 0; i

          notches.add(Point(x,xy.y),Point(x,xy.y–5));

        x += dist;

      }

    }

    label.move(length/3,xy.y+20); // размещает метку под линией

    break;

  }

  case Axis::y:

  { Shape::add(xy); // ось y перемещаем вверх

    Shape::add(Point(xy.x,xy.y–length));

    if (0

      int dist = length/n;

      int y = xy.y–dist;

      for (int i = 0; i

        notches.add(Point(xy.x,y),Point(xy.x+5,y));

        y –= dist;

      }

    }

    label.move(xy.x–10,xy.y–length–10); // размещает метку

                                        // наверху

    break;

  }

  case Axis::z:

    error("ось z не реализована");

  }

}


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

Shape
, унаследованной классом
Axis
(используя функцию
Shape::add()
), хотя деления хранятся в виде отдельного объекта (
notches
). Это позволяет нам манипулировать линией и делениями оси независимо друг от друга; например, мы можем раскрасить их в разные цвета. Аналогично метка была помещена в фиксированное положение, но, поскольку она является независимым объектом, мы всегда можем переместить ее в другое место. Для удобства используем перечисление
Orientation
.

Поскольку класс

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


void Axis::draw_lines() const

{

  Shape::draw_lines();

  notches.draw(); // цвет делений может отличаться от цвета линии

  label.draw();   // цвет метки может отличаться от цвета линии

}


Для рисования объектов

notches
и
label
мы используем функцию
draw()
а не
draw_lines()
, чтобы иметь возможность использовать информацию о цвете, которая в них хранится. Объект класса
Lines
хранится в разделе
Axis::Shape
и использует информацию о цвете, хранящуюся там же.

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


void Axis::set_color(Color c) 

{

  Shape::set_color(c);

  notches.set_color(c);

  label.set_color(c);

}


Аналогично, функция

Axis::move()
перемещает все три части объекта класса
Axis
одновременно.


void Axis::move(int dx, int dy)

{

  Shape::move(dx,dy);

  notches.move(dx,dy);

  label.move(dx,dy);

}

15.5. Аппроксимация

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

Один из способов вычисления экспоненты сводится к суммированию степенного ряда.


e= 1 + x + x2/2! + x3/3! + x4/4! + ...


Чем больше членов ряда мы вычислим, тем точнее будет значение

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


exp0(x) = 0   // нет членов

exp1(x) = 1   // один член

exp2(x) = 1+x // два члена ; pow(x,1)/fac(1)==x

exp3(x) = 1+x+pow(x,2)/fac(2)

exp4(x) = 1+x+pow(x,2)/fac(2)+pow(x,3)/fac(3)

exp5(x) = 1+x+pow(x,2)/fac(2)+pow(x,3)/fac(3)+pow(x,4)/fac(4)

...


Каждая функция немного точнее приближает

ex
, чем предыдущая. Здесь
pow(x,n)
— стандартная библиотечная функция, возвращающая
xn
. В стандартной библиотеке нет функции, вычисляющей факториал, поэтому мы должны определить ее самостоятельно.


int fac(int n) // factorial(n); n!

{

  int r = 1;

  while (n>1) {

    r*=n;

    ––n;

  }

  return r;

}


Альтернативная реализация функции

fac()
описана в упр. 1. Имея функцию
fac()
, можем вычислить n-й член ряда.


double term(double x, int n) { return pow(x,n)/fac(n); } // n-й

                                                         // член ряда


Имея функцию

term()
, несложно вычислить экспоненты с точностью до
n
членов.


double expe(double x, int n) // сумма n членов для x

{

  double sum = 0;

  for (int i=0; i

  return sum;

}


Как построить график этой функции? С точки зрения программиста трудность заключается в том, что наш класс

Function
получает имя функции одного аргумента, а функция
expe()
имеет два аргумента. В языке С++ нет элегантного решения этой задачи, поэтому пока воспользуемся неэлегантным решением (тем не менее, см. упр. 3). Мы можем удалить точность
n
из списка аргументов и сделать ее переменной.


int expN_number_of_terms = 10;

double expN(double x)

{

  return expe(x,expN_number_of_terms);

}


Теперь функция

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


Function real_exp(exp,r_min,r_max,orig,200,x_scale,y_scale);

real_exp.set_color(Color::blue);


Затем выполним цикл приближений, увеличивая количество членов ряда

n
.


for (int n = 0; n<50; ++n) {

  ostringstream ss;

  ss << " приближение exp; n==" << n ;

  win.set_label(ss.str());

  expN_number_of_terms = n;

  // следующее приближение:

  Function e(expN,r_min,r_max,orig,200,x_scale,y_scale);

  win.attach(e);

  win.wait_for_button();

  win.detach(e);

}


Обратите внимание на последний вызов

detach(e)
в этом цикле. Область видимости объекта
e
класса
Function
ограничена телом цикла
for
. Каждый раз, кода мы входим в этот блок, мы создаем новый объект
e
класса
Function
, а каждый раз, когда выходим из блока, объект
e
уничтожается и затем заменяется новым. Объект класса
Window
не должен помнить о старом объекте
e
, потому что он будет уничтожен. Следовательно, вызов
detach(e)
гарантирует, что объект класса
Window
не попытается нарисовать разрушенный объект.

На первом этапе мы получаем окно, в котором нарисованы оси и “настоящая” экспонента (синий цвет).



Как видим, значение

exp(0)
равно
1
, поэтому наш синий график “настоящей” экспоненты пересекает ось y в точке
(0,1)
. Если присмотреться повнимательнее, то видно, что на самом деле мы нарисовали первое приближение
(exp0(x)==0)
черным цветом поверх оси x. Кнопка Next позволяет получить аппроксимацию, содержащую один член степенного ряда. Обратите внимание на то, что мы показываем количество сленгов ряда, использованного для приближения экспоненты, как часть метки окна.



Это функция

exp1(x)==1
, представляющая собой аппроксимацию экспоненты с помощью только одного члена степенного ряда. Она точно совпадает с экспонентой в точке
(0,1)
, но мы можем построить более точную аппроксимацию.



Используя два члена разложения

(1+x)
, получаем диагональ, пересекающую ось y в точке
(0,1)
. С помощью трех членов разложения
(1+x+pow(x,2)/fac(2))
можем обнаружить признаки сходимости.



Десять членов приближения дают очень хорошее приближение, особенно для значений

x
, превышающих
–3
.



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



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

double
, и наши результаты стали отклоняться от правильного ответа. Более подробная информация на эту тему приведена в главе 24.

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

15.6. Графические данные

 Изображение данных требует большой подготовки и опыта. Хорошо представленные данные сочетают технические и художественные факторы и могут существенно облегчить анализ сложных явлений. В то же время эти обстоятельства делают графическое представление данных необъятной областью приложений, в которой применяется множество никак не связанных друг с другом приемов программирования. Здесь мы ограничимся простым примером изображения данных, считанных из файла. Эти данные характеризуют состав возрастных групп населения Японии на протяжении почти столетия. Данные справа от вертикальной линии 2008 являются результатом экстраполяции.



С помощью этого примера мы обсудим следующие проблемы программирования, связанные с представлением данных:

• чтение файла;

• масштабирование данных для подгонки к окну;

• отображение данных;

• разметка графика.


Мы не будем вдаваться в художественные аспекты этой проблемы. В принципе мы строим “график для идиотов”, а не для художественной галереи. Очевидно, что вы сможете построить его намного более красиво, чем это нужно.

Имея набор данных, мы должны подумать о том, как их получше изобразить на экране. Для простоты ограничимся только данными, которые легко изобразить на плоскости, ведь именно такие данные образуют огромный массив приложений, с которыми работают большинство людей. Обратите внимание на то, что гистограммы, секторные диаграммы и другие популярные виды диаграмм на самом деле просто причудливо отображают двумерные данные. Трехмерные данные часто возникают при обработке серии двумерных изображений, при наложении нескольких двумерных графиков в одном окне (как в примере “Возраст населения Японии”) или при разметке отдельных точек. Если бы мы хотели реализовать такие приложения, то должны были бы написать новые графические классы или адаптировать другую графическую библиотеку.

Итак, наши данные представляют собой пары точек, такие как (

year,number of children
). Если у нас есть больше данных, например (
year,number of children,number of adults,number of elderly
), то мы должны просто решить, какую пару или пары чисел хотим изобразить. В нашем примере мы рисуем пары (
year,number of children
), (
year,number of adults
) и (
year,number of elderly
). 

 Существует много способов интерпретации пар (

x,y
). Решая, как изобразить эти данные, важно понять, можно ли их представить в виде функции. Например, для пары (
year,steel production
) разумно предположить, что производство стали (
steel_production
) является функцией, зависящей от года (
year
), и изобразить данные в виде непрерывной линии. Для изображения таких данных хорошо подходит класс
Open_polyline
(см. раздел 13.6). Если переменная
y
не является функцией, зависящей от переменной
x
, например в паре (
gross domestic product per person,population of country
), то для их изображения в виде разрозненных точек можно использовать класс
Marks
(см. раздел 13.15).

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

15.6.1. Чтение файла

Файл с возрастным распределением состоит из следующих записей:


(1960 : 30 64 6)

(1970 : 24 69 7)

(1980 : 23 68 9)


Первое число после двоеточия — это процент детей (возраст 0–15) среди населения, второе — процент взрослых (возраст 15–64), а третье — процент пожилых людей (возраст 65+). Наша задача — прочитать эти данные из файла. Обратите внимание на то, что форматирование этих данных носит довольно нерегулярный характер. Как обычно, мы должны уделить внимание таким деталям.

Для того чтобы упростить задачу, сначала определим тип

Distribution
, в котором будем хранить данные и оператор ввода этих данных.


struct Distribution {

  int year, young, middle, old;

};


istream& operator>>(istream& is, Distribution& d)

 // предполагаемый формат: (год: дети взрослые старики)

{

  char ch1 = 0;

  char ch2 = 0;

  char ch3 = 0;

  Distribution dd;

  if (is >> ch1 >> dd.year

>> ch2 >> dd.young >> dd.middle >> dd.old

>> ch3) {

    if (ch1!= '(' || ch2!=':' || ch3!=')') {

      is.clear(ios_base::failbit);

      return is;

    }

  }

  else

    return is;

  d = dd;

  return is;

}


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

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


string file_name = "japanese-age-data.txt";

ifstream ifs(file_name.c_str());

if (!ifs) error("Невозможно открыть файл ",file_name);

// ...

Distribution d;

while (ifs>>d) {

  if (d.year

    error("год не попадает в диапазон");

  if (d.young+d.middle+d.old != 100)

    error("Проценты не согласованы");

 // ...

}


Иначе говоря, мы пытаемся открыть файл

japanese-age-data.txt
и выйти из программы, если его нет. Идея не указывать явно имя файла в программе часто оказывается удачной, но в данном случае мы пишем простой пример и не хотим прилагать лишние усилия. С другой стороны, мы присваиваем имя файла
japanese-age-data.txt
именованной переменной типа
string
, поэтому при необходимости его легко изменить.

Цикл чтения проверяет диапазон чисел и согласованность данных. Это основные правила проверки таких данных. Поскольку оператор

>>
сам проверяет формат каждого элемента данных, в цикле чтения больше нет никаких проверок. 

15.6.2. Общая схема

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

Open_polyline
— по одному на каждую возрастную группу. Каждый график должен быть помечен. Для этого мы решили в левой части окна записать “название” каждой линии. Этот выбор кажется удачнее, чем обычная альтернатива
clearer
, — поместить метку где-то на самой линии. Кроме того, для того чтобы отличать графики друг от друга, мы используем разные цвета и связываем их с метками.

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

В качестве названия изображения мы решили просто использовать метку окна.

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


const int xmax = 600;   // размер окна

const int ymax = 400;

const int xoffset = 100;// расстояние от левого края окна до оси y

const int yoffset = 60; // расстояние от нижнего края окна до оси х

const int xspace = 40;  // пространство между осями

const int yspace = 40;

const int xlength = xmax–xoffset–xspace; // длина осей

const int ylength = ymax–yoffset–yspace;


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



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

15.6.3. Масштабирование данных

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


const int base_year = 1960;

const int end_year = 2040;

const double xscale = double(xlength)/(end_year–base_year);

const double yscale = double(ylength)/100;


Мы объявили наши масштабирующие множители (

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

Теперь можно поместить точки на ось x, вычитая их базовое значение (

1960
), масштабируя с помощью множителя
xscale
и добавляя смещение
xoffset
. Значение y обрабатывается аналогично. Эти операции тривиальны, но кропотливы и скучны. Для того чтобы упростить код и минимизировать вероятность ошибок (а также, чтобы не приходить в отчаяние), мы определили небольшой класс, в который включили эти вычисления.


class Scale { // класс для преобразования координат

  int cbase; // координатная база

  int vbase; // база значений

  double scale;

public:

  Scale(int b,int vb,double s):cbase(b),vbase(vb),scale(s)
 { }

  int operator()(int v) const

  { return cbase + (v–vbase)*scale; } // см. раздел 21.4

};


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


Scale xs(xoffset,base_year,xscale);

Scale ys(ymax–yoffset,0,–yscale);


Обратите внимание на то, что мы сделали масштабирующий множитель

ys
отрицательным, чтобы отразить тот факт, что координаты y возрастают в направлении вниз, хотя мы привыкли, что они возрастают в направлении вверх. Теперь можем использовать функцию
xs
для преобразования лет в координату
x
. Аналогично можно использовать функцию
ys
для преобразования процентов в координату
y

15.6.4. Построение графика

Итак, у нас есть все предпосылки для создания элегантной программы. Начнем с создания окна и размещения осей.


Window win(Point(100,100),xmax,ymax,"Aging Japan");

Axis x(Axis::x, Point(xoffset,ymax–yoffset),xlength,

      (end_year–base_year)/10,

      "year 1960 1970 1980 1990"

      "2000 2010 2020 2030 2040");

x.label.move(–100,0);


Axis y(Axis::y, Point(xoffset,ymax–yoffset),ylength,

       10,"% of population");


Line current_year(Point(xs(2008),ys(0)),Point(xs(2008),ys(100)));

current_year.set_style(Line_style::dash);


Оси пересекаются в точке

Point(xoffset,ymax–yoffset)
, соответствующей паре (
1960,0
). Обратите внимание на то, как деления отражают данные. На оси y отложено десять делений, каждое из которых соответствует десяти процентам населения. На оси x каждое деление соответствует десяти годам. Точное количество делений вычисляется по значениям переменных
base_year
и
end_year
, поэтому, если мы изменим диапазон, оси автоматически будут вычислены заново. Это одно из преимуществ отсутствия “магических констант” в коде. Метка на оси x нарушает это правило, потому что размещать метки, пока числа на окажутся на правильных позициях, бесполезно. Возможно, лучше было бы задать набор индивидуальных меток для каждого деления.

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


"year 1960 1970 1980 1990"

"2000 2010 2020 2030 2040"


Компилятор конкатенирует такие строки, поэтому это эквивалентно следующей строке:


"year 1960 1970 1980 1990 2000 2010 2020 2030 2040"


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

Объект

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

Построив оси, мы можем обработать данные. Определим три объекта класса

Open_polyline
и заполним их в цикле чтения.


Open_polyline children;

Open_polyline adults;

Open_polyline aged;

Distribution d;

while (ifs>>d) {

  if (d.year

  error("Год не попадает в диапазон");

  if (d.young+d.middle+d.old != 100)

  error("Проценты не согласованы");

  int x = xs(d.year);

  children.add(Point(x,ys(d.young)));

  adults.add(Point(x,ys(d.middle)));

  aged.add(Point(x,ys(d.old)));

}


Использование функций

xs
и
ys
делает проблему масштабирования и размещения данных тривиальной. “Небольшие классы”, такие как
Scale
, могут оказаться очень важными для упрощения кода и устранения лишних повторов — тем самым они повышают читабельность и увеличивают шансы на создание правильной программы.

Для того чтобы графики были более ясными, мы пометили их и раскрасили в разные цвета.


Text children_label(Point(20,children.point(0).y),"age 0-15");

children.set_color(Color::red);

children_label.set_color(Color::red);


Text adults_label(Point(20,adults.point(0).y),"age 15-64");

adults.set_color(Color::blue);

adults_label.set_color(Color::blue);


Text aged_label(Point(20,aged.point(0).y),"age 65+");

aged.set_color(Color::dark_green);

aged_label.set_color(Color::dark_green);


В заключение нам нужно связать разные объекты класса

Shape
с объектом класса
Window
и передать управление системе графического пользовательского интерфейса (см. раздел 15.2.3).


win.attach(children);

win.attach(adults);

win.attach(aged);

win.attach(children_label);

win.attach(adults_label);

win.attach(aged_label);

win.attach(x);

win.attach(y);

win.attach(current_year);

gui_main();


Весь код можно поместить в функцию

main()
, хотя мы предпочитаем использовать вспомогательные классы
Scale
и
Distribution
, а также оператор ввода, определенный в классе
Distribution
.

Если вы забыли, что мы делаем, посмотрите на рисунок.



Задание

Задание, связанное с построением графиков.

1. Создайте пустое окно 600×600 с меткой “Графики функций”.

2. Создайте проект, свойства которого заданы в руководстве по инсталляции библиотеки

FLTK
.

3. Поместите файлы

Graph.cpp
и
Window.cpp
в ваш проект.

4. Добавьте оси x и y длиной по 400 пикселей каждая, с метками “1 == 20 пикселей” и делениями длиной по 20 пикселей. Оси должны пересекаться в точке (300,300).

5. Сделайте обе оси красными.


В дальнейшем используйте отдельный объект класса

Shape
для построения каждой из перечисленных ниже функций.

1. Постройте график функции

double one(double x) { return 1; }
в диапазоне [–10,11] с началом координат (0,0) в точке (300,300), используя 400 точек и не делая масштабирования (в окне).

2. Измените рисунок, применив масштабирование по оси x с коэффициентом 20 и по оси y с коэффициентом 20.

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

4. Добавьте в окно график функции

double slope(double x) { return x/2; }
.

5. Пометьте наклонную линию с помощью объекта класса

Text
со значением "
x/2
" в точке, расположенной прямо над левым нижним углом окна.

6. Добавьте в окно график функции

double square(double x) { return x*x; }
.

7. Добавьте в окно график косинуса (не пишите новую функцию).

8. Сделайте график косинуса синим.

9. Напишите функцию

sloping_cos()
, суммирующую косинус, и функцию
slope()
(как определено выше) и постройте ее график в окне.


Задание, связанное с определением класса.

1. Определите класс

struct Person
, содержащий член name типа
string
и член
age
типа
int
.

2. Определите переменную класса

Person
, инициализируйте ее значением “Goofy” и 63 и выведите на экран (
cout
).

3. Определите оператор ввода (

>>
) и вывода (
<<
) для класса
Person
; считайте объект класса
Person
с клавиатуры (
cin
) и выведите его на экран (
cout
).

4. Напишите конструктор класса

Person
, инициализирующий члены
name
и
age
.

5. Сделайте представление класса

Person
закрытым и включите в него константные функции-члены
name()
и
age()
, предназначенные для чтения имени и возраста.

6. Модифицируйте операторы

>>
и
<<
для заново определенного класса Person.

7. Модифицируйте конструктор, чтобы определить, что переменная

age
лежит в диапазоне [0:150], а переменная
name
не содержит символы
; : " ' [ ] * & ^ % $ # @ !
. В случае ошибки используйте функцию
error()
. Протестируйте программу.

8. Считайте последовательность объектов класса

Person
с устройства ввода (
cin
) в вектор типа
vector
; выведите его на экран (
cout
). Проверьте правильность ввода.

9. Измените представление класса

Person
так, чтобы вместо члена name использовались члены
first_name
и
second_name
. Отсутствие хотя бы одного из этих членов должно считаться ошибкой. Исправьте операторы
>>
и
<<
. Протестируйте программу.


Контрольные вопросы

1. Что такое функция одного аргумента?

2. Когда для представления данных используется непрерывная линия, а когда дискретные точки?

3. Какая функция определяет наклон? Напишите ее математическую формулу.

4. Что такое парабола?

5. Как создать ось x? Как создать ось y?

6. Что такое аргумент, заданный по умолчанию, и зачем он нужен?

7. Как составить сложную функцию?

8. Как при построении графиков используются цвет и метки?

9. Что представляет собой приближение функции с помощью ряда?

10. Зачем разрабатывать эскиз графика перед разработкой кода для его построения?

11. Как масштабировать график?

12. Как масштабировать входные данные без многократных попыток и ошибок?

13. Зачем форматировать входные данные? Не лучше ли рассматривать файл, просто заполненный числами?

14. Как вы разрабатываете общий эскиз графика? Как этот эскиз отражается в вашей программе?


Термины


Упражнения

1. Рассмотрим еще один способ определения функции, вычисляющей факториал.


int fac(int n) { return n>1 ? n*fac(n–1) : 1; } // n!


Эта функция вычисляет значение

fac(4)
. Поскольку
4>1
, ответ равен
4*fac(3)
, т.е.
4*3*fac(2)
, т.е
4*3*2*fac(1)
, т.е.
4*3*2*1
. Посмотрите, как это работает. Функция, вызывающая сама себя, называется рекурсивной (recursive). Альтернативная реализация, описанная в разделе 15.5, называется итеративной (iterative), потому что в ней используется итерация по значениями (в цикле
while
). Убедитесь, что рекурсивная функция
fac()
работает и выдает те же результаты, что и итеративная функция
fac()
при вычислении факториала чисел 0, 1, 2, 3, 4 и так далее до 20. Какую реализацию функции
fac()
вы предпочитаете и почему?

2. Определите класс

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

3. Модифицируйте класс

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

4. Постройте график функций

sin()
,
cos()
,
sin(x)+cos(x)
и
sin(x)*sin(x)+cos(x)*cos(x)
на одном рисунке. Нарисуйте оси и метки.

5. “Анимируйте” (как в разделе 15.5) ряд

1–1/3+1/5–1/7+1/9–1/11+
... Он называется рядом Лейбница (Leibniz) и сходится к числу 
π/4
.

6. Разработайте и реализуйте класс для построения гистограммы. Его исходные данные должны храниться в векторе типа

vector
, в котором записаны N значений и каждое значение представляется “столбиком”, т.е. прямоугольником с соответствующей высотой.

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

8. Ниже перечислено множество пар, составленных из роста и количества людей указанного роста (с точностью до пяти сантиметров): (170,7), (175,9), (180,23), (185,17), (190,6), (195,1). Как изобразить эти данные? Если вы не нашли лучшего решения, постройте гистограмму. Помните об осях и метках. Запишите данные в файл и считайте их оттуда.

9. Найдите другой набор данных о росте людей (дюйм равен 2,54 см) и нарисуйте их с помощью программы, созданной при выполнении предыдущего упражнения. Например, найдите в веб распределение роста людей в США или попросите своих друзей измерить свой рост. В идеале вы не должны изменять свою программу, чтобы приспособить ее к новому набору данных. Для этого следует применить масштабирование данных. Считывание меток также позволит минимизировать количество изменений, если вы захотите повторно использовать программу.

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

11. Найдите среднюю температуру для каждого месяца в нескольких городах (например, Кембридж, Англия, и Кембридж, Массачусетс; в мире масса городов под названием Кембридж) и изобразите их на одном рисунке. Как всегда, помните об осях, метках, цвете и т.д.


Послесловие

Графическое представление данных очень важно. Мы лучше понимаем хорошо нарисованный график, чем совокупность чисел, на основе которых он построен. Когда нужно построить график, большинство людей используют какую-нибудь программу из какой-нибудь библиотеки. Как устроены такие библиотеки и что делать, если их нет под рукой? На каких идеях основаны простые графические инструменты? Теперь вы знаете: это не магия и не нейрохирургия. Мы рассмотрели только двумерные изображения; трехмерные графические изображения также весьма полезны в науке, технике, маркетинге и так далее и даже еще более интересны, чем двумерные. Исследуйте их когда-нибудь! 

Глава 16