Post

《C++程序设计原理与实践》笔记 第13章 图形类

第12章介绍了如何使用一组简单的接口类创建图形。本章将介绍每个接口类的设计、使用和实现。

13.1 图形类概览

作者的GUI库提供的主要接口类:

描述
Color用于设置线、文本及形状填充的颜色
Line_style用于设置线型
Point表示屏幕上和窗口内的位置
Line线段,用两个Point(端点)定义
Open_polyline相连的线段序列,用一系列Point定义
Closed_polyline类似于Open_polyline,但有一条线段连接最后一个点和第一个点
Polygon多边形,即所有线段均不相交的Closed_polyline
Text字符串
Lines线段集合,用多个Point对定义
Rectangle矩形,针对快速、方便地显示进行了优化
Circle圆,用圆心和半径定义
Ellipse椭圆,用圆心和两个半轴定义
Function一元函数,绘制一个区间内的图像
Axis带标签的坐标轴
Mark用字符标记的一个点
Marks一系列带标记的点
Marked_polyline点带有标记的Open_polyline
Image图像文件的内容

第15章将介绍FunctionAxis。第16章介绍主要的GUI接口类:

描述
Window窗口,屏幕的一个区域,用来显示图形对象
Simple_indow带有 “Next” 按钮的窗口
Button按钮,窗口中的矩形构件,通常带标签,可以点击来执行对应的函数
In_box输入框,窗口中的一个框,通常带标签,用户可以在其中输入文本
Out_box输出框,窗口中的一个框,通常带标签,程序可以向其中输出字符串
Menu菜单,Button的向量

源文件组织如12.4节所示。

除了图形类,GUI库还提供了一个用于保存ShapeWidget的容器类Vector_ref

13.2 Point和Line

在任何图形系统中,(point)都是最基本的部分。这里使用整数坐标(x, y)来定义点,如12.5节所述。Point就是一对int,定义在Point.h中:

1
2
3
4
5
struct Point {
    int x, y;
    Point(int xx, int yy) :x(xx), y(yy) {}
    Point() :x(0), y(0) {}
};

Graph.h定义了ShapeLine

1
2
3
4
5
6
7
class Shape {
    // ...
}

struct Line : Shape {
    Line(Point p1, Point p2);
};

其中,: Shape意味着“Line是一种Shape”。Shape称为Line基类(base class),将在第14章进行解释。

Line由两个Point定义。下面的程序创建并绘制了两条线:

绘制线段

绘制线段

Line的构造函数的实现非常简单:

1
2
3
4
Line::Line(Point p1, Point p2) {    // construct a line from two points
    add(p1);    // add p1 to this shape
    add(p2);    // add p2 to this shape
}

即简单地“添加”了两个点。添加到哪里?Line是如何在窗口中绘制的?答案在Shape类中,我们将在第14章介绍,Shape能够保存一些点、绘制由点对构成的线,并提供了add()函数来添加一个点。

13.3 Lines

我们很少仅仅画一条线。对象通常由很多条线组成,例如多边形、路径、迷宫、网格、柱状图、数学函数数据图等。最简单的“复合图形”是Lines

1
2
3
4
struct Lines : Shape {                 // related lines
    void draw_lines() const;
    void add(Point p1, Point p2);      // add a line defined by two points
};

Lines对象就是一个线的集合,每条线由一对Point定义。例如,13.2节的例子中的两条线可以作为单个对象:

绘制线段2

绘制线段2

一组Line对象和一个Lines对象中的一组线的区别完全是我们看问题的视角不同。使用Lines,我们是想表达两条线是联系在一起的,必须一起处理。例如,我们使用单个命令就可以改变Lines对象中所有线的颜色。另一方面,我们可以为每个Line对象设置不同的颜色。一个更实际的例子是定义网格。网格由一些等间隔的水平线和垂直线组成,我们将网格视为一个整体,因此将这些线定义为一个Lines对象grid

绘制网格

注意这里使用x_max()y_max()获得窗口的尺寸。

绘制网格

Lines的成员函数add()用于添加一条线(由一对点定义):

1
2
3
4
void Lines::add(Point p1, Point p2) {
    Shape::add(p1);
    Shape::add(p2);
}

其中限定符Shape::是必需的,否则编译器会调用Linesadd()(非法)而不是Shapeadd()

draw_lines()函数绘制add()定义的线:

1
2
3
4
5
void Lines::draw_lines() const {
    if (color().visibility())
        for (int i=1; i<number_of_points(); i+=2)
            fl_line(point(i-1).x,point(i-1).y,point(i).x,point(i).y);
}

即每次取两个点,并使用底层库(FLTK)的画线函数fl_line()来绘制两点之间的线。

draw_lines()是(在调用win.wait_for_button()之后)被GUI系统调用的。我们不需要检查点的数目是否为偶数,因为Lines::add()每次只能添加两个点。函数number_of_points()point()定义在Shape类中(见14.2节)。成员函数draw_lines()不修改形状,因此将其定义为const

Lines的默认构造函数创建一个空对象,即开始没有线,按需要逐步添加。另外,也可以定义一个接受初始化列表的构造函数:

1
2
3
4
void Lines::Lines(initializer_list<pair<Point, Point>> lst) {
    for (auto p : lst)
        add(p.first, p.second);
}

其中,auto表示让编译器自动推断类型(这里是pair<Point, Point>),firstsecond是标准库类型pair的两个成员,标准库类型initializer_list表示初始化列表。从而可以以字面值的形式创建Lines对象:

1
2
3
4
Lines x = {
    {Point(100, 100), Point(200, 100)},  // first line: horizontal
    {Point(150, 50), Point(150, 150)}    // second line: vertical
};

或者

1
2
3
4
Lines x = {
    { {100, 100}, {200, 100} },  // first line: horizontal
    { {150, 50}, {150, 150} }    // second line: vertical
};

其中,{100, 100}表示一个Point{ {100, 100}, {200, 100} }表示一个pair<Point, Point>,整个初始化列表表示一个initializer_list<pair<Point, Point>>

13.4 Color

Color是用于表示颜色的类型。可以像这样使用:

1
grid.set_color(Color::red);

绘制红色网格

Color定义了颜色的表示方法(Fl_Color),并给出了一个常用颜色的符号名字(Color_type枚举),见Graph.h。

注:FLTK使用Fl_Color类型(unsigned int的别名)表示颜色,即一个32位无符号整数0xrrggbbii,该整数有两种含义:

  • 低8位ii表示FLTK默认颜色表中的索引,范围为0~255,例如99为暗绿色
  • 高24位rrggbb表示RGB颜色值,其中rr、gg和bb分别是红、绿、蓝分量,范围都是0~255,例如0x2B91AF00 = RGB(43, 145, 175) = 蓝绿色

Color的目标是:

  • 隐藏实现的颜色表示方式,即FLTK的Fl_Color类型
  • Color_type映射到Fl_Color
  • 给颜色常量一个作用域
  • 提供一个简单的透明度机制(可见和不可见)

有几种方式选择颜色:

Color的构造函数允许从Color_type或者普通的int创建Color对象,例如:

1
2
3
Color red = Color::red;
Color green = 0x00FF0000;
Color blue = 4;

Color提供了as_int()函数,返回颜色对应的int值。

颜色的透明度/可见性用Color::visibleColor::invisible。例如,如果不想显示形状的轮廓,只显示填充颜色,可以将轮廓颜色设置为不可见:

1
2
r.set_color(Color::invisible);
r.set_fill_color(Color::red);

13.5 Line_style

线性是描述线的外形的一种模式。可以像这样使用Line_style

1
grid.set_style(Line_style::dot);

绘制红色点线网格

也可以调整线宽(粗细)。Line_style类型也定义在Graph.h。

定义Line_style所使用的编程技术与Color完全一样——隐藏了FLTK使用普通int表示线型的细节,因为这些细节可能会随着库的升级而发生变化。

大多数情况下,我们无需关心线型,使用默认值即可(默认宽度和实线)。Line_style包括两部分:样式(例如实线或虚线)和宽度(粗细)。宽度用整数表示,默认为0。例如,可以像这样设置加粗的虚线:

1
grid.set_style(Line_style(Line_style::dash, 2));

绘制红色加粗虚线网格

注意,颜色和线型会对形状中的所有线起作用,这是将许多线组合为单个图形对象(例如LinesOpen_polylinePolygon)的好处之一。如果想分别控制线的颜色或线型,必须将它们定义为独立的Line对象。例如:

1
2
horizontal.set_color(Color::red);
vertical.set_color(Color::green);

绘制有颜色的线段

13.6 Open_polyline

Open_polyline是由一系列依次相连的线段组成的形状,由一系列点定义。 “Poly” 是希腊语中“许多”的意思, “polyline” 表示由许多线组成的形状。例如:

1
Open_polyline opl = { {100, 100}, {150, 200}, {250, 250}, {300, 200} };

绘制Open_polyline

绘制Open_polyline

Open_polyline类的定义如下:

1
2
3
4
5
6
struct Open_polyline : Shape {         // open sequence of lines
    Open_polyline() :Shape() {}
    Open_polyline(initializer_list<Point> points) :Shape(points) {}
    void add(Point p) { Shape::add(p); }
    void draw_lines() const override;
};

Open_polyline继承自Shape两个构造函数分别调用了Shape对应的构造函数,上面的程序使用了初始化列表构造函数。

  • 注:书中给出的定义使用using声明(using Shape::Shape;)继承了Shape的构造函数,但Shape的构造函数是protected,通过using继承的构造函数也是protected,无法在程序中使用。

Open_polylineadd()函数是为了允许用户访问Shape::add()(本身是protected)。不必定义draw_lines(),因为Shape类的默认定义就是用线依次连接通过add()添加的点。

13.7 Closed_polyline

Closed_polylineOpen_polyline类似,唯一区别是还需要画一条从最后一个点到第一个点的线。例如:

绘制Closed_polyline

绘制Closed_polyline

Closed_polyline的定义为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Closed_polyline : Open_polyline { // closed sequence of lines
    using Open_polyline::Open_polyline;
    void draw_lines() const override;
};

void Closed_polyline::draw_lines() const {
    Open_polyline::draw_lines();    // first draw the "open poly line part"
    // then draw closing line:
    if (number_of_points()>2 && color().visibility())
        fl_line(point(number_of_points()-1).x, 
            point(number_of_points()-1).y,
            point(0).x,
            point(0).y);
}

Closed_polyline需要定义自己的draw_lines()来绘制连接最后一个点到第一个点的线。我们只需编写Closed_polylineOpen_polyline不同的部分即可:调用FLTK的画线函数fl_line()来绘制最后一条线,直接调用Open_polyline::draw_lines()来绘制其他的线。

13.8 Polygon

PolygonClosed_polyline非常相似,唯一的区别是Polygon不允许交叉的线。例如,上一节中的Closed_polyline是一个多边形,但如果再添加一个点:cpl.add(Point(100, 250));,则不再是一个多边形:

绘制Closed_polyline 2

Polygon是不存在交叉线的Closed_polyline,因此可以让Polygon继承Closed_polyline,并在add()函数中检查是否有线段相交:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Polygon : Closed_polyline {    // closed sequence of non-intersecting lines
    using Closed_polyline::Closed_polyline;
    void add(Point p);
    void draw_lines() const override;
};

void Polygon::add(Point p) {
    // check that the new line doesn't intersect existing lines (code not shown)
    Closed_polyline::add(p);
}

void Polygon::draw_lines() const {
    if (number_of_points() < 3) error("less than 3 points in a Polygon");
    Closed_polyline::draw_lines();
}

通过继承节省了大量工作,还避免了重复代码。不幸的是,每次调用add()都需要检查是否有线段相交,这导致一个低效的(O(N2))算法——定义一个具有N个点的Polygon需要做N*(N-1)/2次检查。因此,我们假设Polygon只用于顶点数较少的多边形。

例如:

绘制多边形

绘制多边形

Polygon::add()中省略的相交检查是整个GUI库中最复杂的部分。麻烦在于Polygon的不变式“这些点表示一个多边形”只有在定义了所有点之后才能被验证,因此无法在构造函数中建立不变式(虽然这是最好的方式)。

13.9 Rectangle

屏幕上最常见的形状是矩形。因此GUI系统直接支持矩形,而不是当作四个角恰好都是直角的多边形。

1
2
3
4
5
6
7
8
9
10
11
struct Rectangle : Shape {
    Rectangle(Point xy, int ww, int hh);
    Rectangle(Point x, Point y);
    void draw_lines() const override;

    int height() const { return h; }
    int width() const { return w; }
private:
    int h;    // height
    int w;    // width
};

可以使用两个点(左上角和右下角)或者一个点(左上角)和宽度、高度来定义矩形。

绘制矩形

绘制矩形

当不设置填充颜色时,矩形是透明的,因此可以看到黄色矩形rect00的一角。

可以在窗口内移动形状:

1
2
rect11.move(400, 0);    // to the right of rect21
rect11.set_fill_color(Color::white);

绘制矩形2

注意,白色矩形rect11位于窗口之外的部分被“剪裁”掉了。

另外请注意形状的层次:后绘制的形状会覆盖先绘制的形状。GUI库的Window类提供了一种重新排列形状次序的方法:put_on_top()将一个形状放在顶层(必须在attach()之后调用)。例如:

1
win.put_on_top(rect00);

绘制矩形3

可以看到,即使矩形有填充颜色仍然有边框,可以将其移除:

1
rect00.set_color(Color::invisible);

绘制矩形4

注意,在填充颜色和线的颜色都被设置为invisible后,rect22就看不到了。

Rectangledraw_lines()必须处理线的颜色和填充颜色,因此有些复杂:

1
2
3
4
5
6
7
8
9
10
11
12
void Rectangle::draw_lines() const {
    if (fill_color().visibility()) {    // fill
        fl_color(fill_color().as_int());
        fl_rectf(point(0).x,point(0).y,w,h);
        fl_color(color().as_int());    // reset color
    }

    if (color().visibility()) {    // lines on top of fill
        fl_color(color().as_int());
        fl_rect(point(0).x,point(0).y,w,h);
    }
}

FLTK提供了绘制矩形填充(fl_rectf())和矩形轮廓(fl_rect())的函数。默认情况下,我们两者都绘制(轮廓在上)。

13.10 管理未命名对象

到目前为止,所有图形对象都是命名的。当处理大量对象时,这种方法就不可行了。例如,绘制FLTK调色板中256中颜色构成的颜色表,即绘制256个不同颜色填充的格子,构成一个16×16的矩阵,如下图所示。

绘制16×16颜色表

命名256个格子不但繁琐,而且不明智。任何一个格子都可以用坐标(i, j)来标识,左上角的格子是(0, 0)。因此我们需要一种表示对象矩阵的方法。无法使用vector<Rectangle>,因为Shape类不可拷贝;使用vector<Rectangle*>则需要手动delete。本例的解决方案:采用一种能够保存命名和未命名对象的向量类型:

1
2
3
4
5
6
7
8
9
10
11
template<class T> class Vector_ref {
public:
    // ...
    void push_back(T& s);  // add a named object
    void push_back(T* p);  // add an unnamed object

    T& operator[](int i);  // subscripting: read and write access
    const T& operator[](int i) const;

    int size() const;
};

与标准库vector的使用方法非常类似:

1
2
3
4
5
6
7
8
9
Vector_ref<Rectangle> rect;

Rectangle x(Point(100, 200), Point(200, 300));
rect.push_back(x);  // add named

rect.push_back(new Rectangle(Point(50, 60), Point(80, 90)));  // add unnamed

for (int i = 0; i < rect.size(); ++i)
    rect[i].move(10, 10);  // use rect

第17章将解释new运算符。Vector_ref的实现在Graph.h,现在只知道可以用它保存未命名对象就够了。

可以这样绘制颜色表:

绘制16×16颜色表

13.11 Text

Text用于显示文本。例如,为13.8节中“奇怪”的Closed_polyline添加标签:

1
2
Text t(Point(200, 200), "A closed polyline that isn't a polygon");
t.set_color(Color::blue);

显示文本

Text对象定义了以给定的Point为左下角的一行文本。限制文本为单行的原因是保证跨系统的可移植性。不要尝试放入换行符,在窗口中不一定有效果(经过测试,在Windows系统上确实无效)。字符串流对于构造Text中显示的字符串是很有用的。

Text的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct Text : Shape {
    // the point is the bottom left of the first letter
    Text(Point x, const string& s) : lab(s), fnt(fl_font()), fnt_sz(fl_size()) { add(x); }

    void draw_lines() const override;

    void set_label(const string& s) { lab = s; }
    string label() const { return lab; }

    void set_font(Font f) { fnt = f; }
    Font font() const { return Font(fnt); }

    void set_font_size(int s) { fnt_sz = s; }
    int font_size() const { return fnt_sz; }
private:
    string lab;    // label
    Font fnt;
    int fnt_sz;
};

void Text::draw_lines() const {
    fl_font(fnt.as_int(),fnt_sz);
    fl_draw(lab.c_str(),point(0).x,point(0).y);
}

字符的颜色和形状颜色一样,可以通过set_color()设置。Graph.h中的Font类提供了一些预定义的字体。

13.12 Circle

Circle是由圆心和半径定义的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
struct Circle : Shape {
    Circle(Point p, int rr);    // center and radius

    void draw_lines() const override;
    Point center() const;
    void set_radius(int rr) {
        set_point(0,Point(center().x-rr,center().y-rr));
        r=rr;
    }
    int radius() const { return r; }
private:
    int r;
};

Circle::Circle(Point p, int rr)    // center and radius
    :r(rr) {
    add(Point(p.x-r,p.y-r));       // store top-left corner
}

Point Circle::center() const {
    return Point(point(0).x+r, point(0).y+r);
}

void Circle::draw_lines() const {
  	if (fill_color().visibility()) {	// fill
		fl_color(fill_color().as_int());
		fl_pie(point(0).x,point(0).y,r+r-1,r+r-1,0,360);
		fl_color(color().as_int());	// reset color
	}
	if (color().visibility()) {
		fl_color(color().as_int());
		fl_arc(point(0).x,point(0).y,r+r,r+r,0,360);
	}
}

可以像这样使用Circle

绘制圆

绘制圆

Circle类实现的奇怪之处是它存储的点不是圆心,而是外接正方形的左上角,因为FLTK的画圆函数fl_arc()使用这个点。Circle提供了一个例子:对于一个概念,一个类如何呈现与其实现不同的(可能更好的)视角。

fl_arc()函数用于绘制椭圆的弧,其中前两个参数表示椭圆外接矩形的左上角,之后两个参数是矩形的宽和高(即椭圆的长轴和短轴),最后两个参数是绘制的起止角度(0~360)。

13.13 Ellipse

EllipseCircle类似,但通过圆心、半长轴和半短轴定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
struct Ellipse : Shape {
    Ellipse(Point p, int ww, int hh);   // center, min, and max distance from center

    void draw_lines() const override;

    Point center() const;
    Point focus1() const;
    Point focus2() const;
    
    void set_major(int ww) { set_point(0,Point(center().x-ww,center().y-h)); w=ww; }
    int major() const { return w; }
    void set_minor(int hh) { set_point(0,Point(center().x-w,center().y-hh)); h=hh; }
    int minor() const { return h; }
private:
    int w;
    int h;
};

void Ellipse::draw_lines() const {
   if (fill_color().visibility()) {	// fill
		fl_color(fill_color().as_int());
		fl_pie(point(0).x,point(0).y,w+w-1,h+h-1,0,360);
		fl_color(color().as_int());	// reset color
	}
	if (color().visibility()) {
		fl_color(color().as_int());
		fl_arc(point(0).x,point(0).y,w+w,h+h,0,360);
	}
}

可以像这样使用Ellipse

绘制椭圆

绘制椭圆

在几何上,椭圆的长轴与短轴相等时看起来就是一个圆。GUI库没有把Circle定义为Ellipse的子类,因为这样会增加一个成员,带来不必要的空间开销。但主要原因是必须set_major()set_minor(),使类的定义变得更加复杂(这和Rectangle不是Polygon的子类的原因是类似的)。

在设计类时,我们应该小心不要自作聪明,也不要被“直觉”欺骗。相反,我们应该注意如何用类表达某些概念,而不仅仅是数据和函数成员的集合。不思考要表达的思想/概念,只是将代码简单地堆积在一起会导致难以解释、难以调试、难以维护的代码。

13.14 Marked_polyline

我们通常需要对图中的点做“标记”。Marked_polyline就是点带有“标记”的Open_polyline。例如:

绘制Marked_polyline

绘制Marked_polyline

Marked_polyline的定义为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Marked_polyline : Open_polyline {
    Marked_polyline(const string& m) :mark(m) { if (m=="") mark = "*"; }
    Marked_polyline(const string& m, initializer_list<Point> points)
        :Open_polyline(points), mark(m) {
        if (m=="") mark = "*";
    }
    void draw_lines() const override;
private:
    string mark;
};

void Marked_polyline::draw_lines() const {
    Open_polyline::draw_lines();
    for (int i=0; i<number_of_points(); ++i) 
        draw_mark(point(i),mark[i%mark.size()]);
}

通过继承Open_polyline,我们“免费”获得了对点的处理,因此只需处理标记。Marked_polyline::draw_lines()首先调用Open_polyline::draw_lines()画线,之后依次选择字符串中的字符绘制标记:mark[i%mark.size()]通过取模运算循环遍历字符串mark,选择下一个标记字符。绘制标记字符使用了辅助函数draw_mark()

1
2
3
4
5
6
7
void draw_mark(Point xy, char c) {
    static const int dx = 4;
    static const int dy = 4;

    string m(1,c);
    fl_draw(m.c_str(),xy.x-dx,xy.y+dy);
}

其中常量dxdy用于使字符位居中,字符串m被初始化为单个字符c

13.15 Marks

有时,我们需要显示没有线连接的标记,因此提供了Marks类。例如:

绘制Marks

绘制Marks

Marks就是线的颜色是invisibleMarked_polyline

1
2
3
4
5
6
7
8
struct Marks : Marked_polyline {
    Marks(const string& m) :Marked_polyline(m) {
        set_color(Color(Color::invisible));
    }
    Marks(const string& m, initializer_list<Point> points) :Marked_polyline(m, points) {
        set_color(Color(Color::invisible));
    }
};

:Marked_polyline(m)表示调用基类的构造函数。这种语法是成员初始化语法的一个变体。

13.16 Mark

Mark用于标记单个点,由一个点和一个字符初始化。例如:

绘制标记圆心的圆

绘制标记圆心的圆

Mark就是直接给定一个点和字符的Marks

1
2
3
4
5
struct Mark : Marks {
    Mark(Point xy, char c) : Marks(string(1,c)) {
        add(xy);
    }
};

string(1, c)string的一个构造函数,将字符串初始化为仅包含单个字符c

13.17 Image

我们希望在程序中显示图像。例如,下面的程序显示了飓风Rita到达得克萨斯州墨西哥湾的路线图的一部分,并加入从太空中拍摄的Rita的照片:

绘制图像

绘制图像

set_mask()选择要显示图像的一个子图像。这里我们从图像rita_path.gif(加载到path)选择了一个600×400像素大小、左上角位于path中的(50, 250)的子图像。

形状按照附加到窗口的顺序确定层次。由于path先于rita附加到窗口,因此位于rita下层。

图像的编码格式非常多,GUI库只处理最常用的两种,JPEG和GIF:

1
Suffix get_encoding(const string& s);

在GUI库中,使用Image类的对象表示内存中的图像:

1
2
3
4
5
6
7
8
9
10
11
struct Image : Shape {
    Image(Point xy, string file_name, Suffix e = Suffix::none);
    ~Image() { delete p; }
    void draw_lines() const override;
    void set_mask(Point xy, int ww, int hh) { w=ww; h=hh; cx=xy.x; cy=xy.y; }
private:
    int w,h;  // define "masking box" within image relative to position (cx,cy)
    int cx,cy; 
    Fl_Image* p;
    Text fn;
};

Image的构造函数使用给定的文件名打开文件,然后按参数或文件后缀指定的编码格式创建图像。如果图像无法显示(例如未找到文件)则显示Bad_image (☒)。

简单练习

魔塔

习题

This post is licensed under CC BY 4.0 by the author.