Support for Object-Based Programming I
对基于对象编程的支持
在前面章节我们采用下面的三个步骤定义了基于对象的编程:
1. 决定想要什么类型?
2. 为每一个类型指定表示法
3. 为每一个类型提供一完整的操作集
对于数据抽象的编程最基本支持包含一些语言提供的特性:定义类型、为这些类型定义操作集(函数和运算--希望自定义类型和内建类型一样)、还有对这些类型对象的访问控制。本章将引入C++支持数据抽象的语言机制。
II-1 Basics 基本概念
II-1.1 Defining types 定义数据类型
C++引入了class关键字,用于用户定义类型。例如,也许建立最简单的一个类型empty如下:
class empty
{
// empty
};
II-1.2 Access control
基于对象编程范式的需要,C++提供了对一个class访问限制部分的显式的访问控制。这透过private和public关键字实现(当然C++还提供了protected关键字,在private和public之间折衷了一下)。例如,定义一个类型foo,有一个隐藏部分part1和一个可见部分part2,可实现如下:
class X // introduces new type X
{
private: // hidden part1 begins here
// hidden part1 ends here
public: // visible part2 begins here
// visible part2 ends here
};
II-1.3 A schematic class definition
一般来说,C++类定义隐藏了类型的表示以及对用户来说不感兴趣的一些内部工具部分。可视部分包括创建和销毁一个对象以及读取或修改对象属性的函数。进一步地,还包括一些运算操作的实现,也许还有其他函数。小结如下,一般类定义框架如下:
class prototype
{
public:
// initializing
// clean-up
// assignment
// accessors
// modifiers
// operators
// friends
private:
// representation
// utilities
};
本章随后的小节,我们会详细研究下这个prototype类。
II-2 Initialization and Cleanup 初始化和释放资源
当一个类型的表示法被隐藏,那么就该提供一些机制让用户初始化该类型。C++允许类型的设计者提供一个区别于其他的特别函数来做初始化。有了这函数,内存分配和变量初始化变成了一单一的操作(通常称之为instantiation实例化或者叫构造)而不是两个分离的操作。这样一个初始化函数称之为构造子(constructor)。额外地,除了这重要的类型对象的俄构造,对于最后清除该对象的相应操作也提供专门函数完成。在C++里,这清除函数称之为析构子(destructor)。考虑下面vector类型的例子:
class vector
{
private:
int sz;
int* data;
public:
vector(int);
瘃攀挀琀漀爀();
// ...
};
vector构造子可以如下定义,分配空间:
vector::vector(int s)
{
if (s <= 0) error("bad vector size");
sz = s;
data = new int[s]; // allocate an array of "s" integers
}
vector析构子释放存储,定义如下:
vector:: 瘃攀挀琀漀爀()
{
delete[] data;
}
C++语言本身不支持垃圾回收(garbage collection)。这可以透过让类型自己维护自己的存储管理,不需要用户的干涉,从而弥补C++缺少垃圾回收的机制。
II-2.1 The scope operator 域操作符
在前面一节,我们实现constructor和destructor时,用到了scope operator::来表明vector(int)是vector类的一个成员函数。一般地,一个类X的foo成员函数可以两种方式定义。第一种就是在类X里声明和实现foo:
class X
{
// ...
public:
void foo()
{
// implementation
}
};
第二种方式:在类X里声明foo,但是在类X外面实现:
class X
{
// ...
public:
void foo();
};
void X::foo()
{
// implementation
}
II-3 Assignment and Initialization 赋值和初始化
对象的构造和析构的控制对于许多类型已经足够了,但不是所有的类型都会满足。还有必要控制所有的拷贝操作。继续考虑类vector:
vector v1(100);
vector v2 = v1; // make a new vector v2 initialized to v1
v1 = v2; // assign v2 to v1
C++里,是可以这样定义的:初始化v2,然后赋值给v1,例如:
class vector
{
private:
int sz; // number of elements
int* data; // pointer to integers
public:
// ...
vector(const vector &); // initialization
void operator = (const vector &); // assignment
};
表明用户可以定义操作来提供实现初始化和vector对象的赋值。赋值可以这么实现:
void vector::operator=(const vector& a)
{
if (sz != a.sz) error("bad vector sizes");
for (int i = 0; i < sz; i++)
data[i] = a.data[i];
}
因为赋值操作依赖于被赋值的vector旧有的值,因此初始化操作就有点不同,例如:
// initialize a vector from another vector
vector::vector(const vector& a)
{
sz = a.sz; // same size
data = new int[sz]; // allocate element array
for (int i = 0; i < sz; i++) // and copy elements
data[i] = a.data[i];
}
C++里这种X(const X &)构造函数称之为拷贝构造函数(copy constructor)。除了显式初始化拷贝,构造函数还用来处理透过传值的参数以及函数返回值。可以把赋值声明为private,从而阻止对象赋值给另一个对象:
class X
{
private:
X(const X &);
void operator = (const X &);
public:
// ...
};
II-4 Accessors and Modifiers
在对象上的操作不是读取对象属性就是修改对象的属性。读取访问不是特重要的操作,它不会改变对象。因此对象在这样的操作访问后保持一致性。而对对象属性修改操作显得更关键。例如,对修改对象的放入引入一个bug几乎总是导致程序崩溃,因为数据被破坏了。出现这样问题的程序可能会丢失数据和金钱^_^。
因此这是很重要的问题,需要我们能够将accessor和modifier区别开来。我们有更多更好的语言层面的支持。
II-4.1 The const keyword const 关键字
C++里,我们能够用const关键字将constant(非修改的)与non-constant(可修改的)变量区分开来。例如,下面序列会导致编译错误:
const int x = 3; // x is initialized to 3
x = 4; // compiler error: x is const
...
回忆一下类vector定义,我们可以增加一个size方法,返回vector分配空间大小。而该方法不会修改对象,那么可以声明为const:
int size() const { return sz; }
constant对象就是一个一旦初始化后就不能被改变的对象,因此一个constant方法是一个不会修改对象属性的方法,因此可以作用于constant对象。
对于modifier方法,典型的例子就是设置大小函数set_size,对给定的vector对虾概念重新分配大小。注意到,下面定义没有了const:
int set_size(int);
set_size可以这么实现:
int vector::set_size(int new_sz)
{
int old_sz = sz;
int *old_data = data;
if (new_sz < 0) error("illegal size");
sz = new_sz;
if (sz == 0)
delete[] data;
else
{
data = new int [sz];
for (int i = 0; i < min(old_sz, sz); i++)
data[i] = old_data[i];
delete[] old_data;
}
return old_sz;
}
可能有人会问,为什么要在像size这样的方法上加const关键字,该方法显然也没有改变vector属性。答案如下:
添加了const,让该类潜在的阅读者和维护者很清楚知道,你的设计是使用该函数作为一个accessor。这是个不错的编程风格
如果你的const方法偶然修改了对象,在编译期间会捕捉到这错误。因此,使用const减少bug
添加const,可以让编译器在constant对象上使用该函数。在C++里,不能在constant对象上使用non-constant方法;如果试图这么做,会得到一个错误。因此,如果使用了const,让程序更安全。
[int get_size(void) const /* 错误: 非成员函数‘int get_size()’不能拥有 cv 限定符*/]
II-5 Operators
在II-3 小节,我们对赋值=运算符提供了一个新含义,这样才能正确控制vector对象的正确赋值。这样的动作在C++里叫运算符重载operator overloading。C++允许很多运算符可以被重载,例如+、-、*、/、!等等。
例如,对于vector索引运算符[ ],可以定义如下:
class vector
{
private:
int sz; // number of elements
int* data; // pointer to integers
public:
// ...
int operator [] (int i) const;
};
实现如下:
int vector::operator[] (int i) const
{
if (i < 0 || i >= sz) error("bad vector index");
return data[i];
}
索引运算符典型应用是在函数norm里的实现,该函数计算一个vector里所有元素的平方和:
int norm(vector v)
{
int sum = 0;
for (int i = 0; i < v.size(); i++)
sum = sum + v[i] * v[i];
return sum;
}
norm可以这么使用:
vector v(100);
// ...
int norm_v = norm(v);
II-5.1 References to objects 对象的引用
前面小节,已经实现了vector的读取索引运算符。这意味着下面的语句会产生编译错误:
vector v(100);
int i = v[1]; // ok
v[1] = 123; // oops: v[1] is not writable
为了能够做上面那样的赋值,索引运算符必须返回需要访问的vector元素的引用。因此,我们需要提供额外的一个能够写的索引方法(writing index operator-C++里的术语,写索引运算符返回一个l-value[左值]):
int & operator [] (int i);
实现如下:
int & vector::operator[] (int i)
{
if (i < 0 || i >= sz) error("bad vector index");
return data[i];
}
尽管上面括号的里的实现和前面一样,还是有两点不同。语法上的诧异是返回类型int增加了&,移掉了const关键字。语义上的差异是读索引运算符返回第i个元素拷贝,而写索引运算符返回第i个元素本身。这样有了这些运算符,前面的例子编译就正确了:
vector v(100);
int x = v[1]; // ok
v[1] = 123; // ok: v[1] is writable
当然,引用概念也适用于这样的情形,当我们在一个函数里返回更多个结果时。考虑下面的例子min_max过程。给定俩个整数a和b,min_max在其第一个第二个参数里返回最小的和最大的:
void min_max(int & min, int & max, int a, int b)
{
if (a < b) { min = a; max = b; }
else { min = b; max = a; }
}
min max can be called like this
int x, y;
min_max(x, y, 5, 7);
// ...
II-5.2 Passing values to functions 传递值给函数
使用对象应用可以避免对大对象的昂贵的拷贝操作开销。再考虑norm函数:
int norm(vector v)
{
int sum = 0;
for (int i = 0; i < v.size(); i++)
sum = sum + v[i] * v[i];
return sum;
}
当用户调用norm,传入参数一个vector对象x,编译器创建x的一个拷贝,然后传递给函数norm。这比需要保证x的值没有丢失。以这种方式传递参数给函数叫值传递(passing by value)。现在考虑如果我们调用一个size很大的vector多次:程序需要花费更多时间来拷贝。
结合const关键字,C++提供一个相当安全,但是很高效的方式来实现同样的功能:
int norm(const vector & v)
{
// same code as before
}
当调用norm时,参数是vector对象x,只传递了x的引用,因此避免了拷贝。增加const关键字,确保编译器不允许对引用对象的修改。这种传递参数方式称之为引用传递(passing by reference),当传递大对象的时候,我们总应该用这种方式。
II-6 Friends 友元
在II-5 小节,我们实现了一个norm函数,使用类vector的[ ]运算符。再看看改进的版本(引用传递):
int norm(const vector & v)
{
int sum = 0;
for (int i = 0; i < v.size(); i++)
sum = sum + v[i] * v[i];
return sum;
}
这不是唯一可能的实现。我们可以把norm实现成一个成员函数:
int norm() const
{
int sum = 0;
for (int i = 0; i < sz; i++)
sum = sum + data[i] * data[i];
return sum;
}
成员函数norm可以像这样被用户调用:
vector v(100);
// ...
int norm_v = v.norm();
在两种实现之间有什么区别呢?首先一看,我们注意到调用语法不同:成员函数在vector对象v上使用dot运算符调用成员函数,而我们II-5小节的第一个版本把v作为参数传递给函数norm。从用户的角度看,这比较接近人们常用的数学符号。
然而,在效率方面有很大的差异:前面的功能性版本需要调用200此[ ]运算,而成员方法版本不需要调用该运算,直接在数据上运算。
C++提供了一种方法实现norm,同时满足自然的符号表现形式以及速度方面的考量--这就是把该函数声明为友元(friend)。使用这个关键字,新的norm实现如下:
friend int norm(const vector & v)
{
int sum = 0;
for (int i = 0; i < sz; i++)
sum = sum + v.data[i] * v.data[i];
return sum;
} /*[如何再优化?]*/
这样,friend函数不是类成员函数,但是可以访问类的私有部分。
II-6.1 Type conversions 类型转换
很自然对于函数norm两种不同的实现有些疑问(成员、友元):哪一个更好?答案是:最符合你需要的就是最好的:)。为了解释这概念,先把vector例子放一边,重新看看rational类的实现,以及它的+运算符。我们首先试着把这运算符实现为rational类的友元函数:
class rational
{
private:
int num, den;
public:
rational() { num = 0; den = 1; }
rational(int n) { num = n; den = 1; }
rational(int n, int d) { ... }
爃愀琀椀漀渀愀氀() { } // yes, it is empty
friend rational operator + (rational x, rational y);
...
};
有了这样的定义,下面的编译就是正确的:
rational x = 1; // line 1: x = rational(1)
rational y = 2; // line 2: y = rational(2)
y = 3 + x; // line 3: implicit: 3 -> rational(3)
因为编译器自动产生类型转换(type conversion),在整数3上调用构造函数rational(int)。如果将友元函数实现改为成员函数:
rational operator + (rational x);
前面的语句就会产生编译错误。为什么?考虑这样代码:
y = 3 + x; // oops: y = 3.operator+(y);
3是整型,而int不是一个类
另一个例子(也许更典型)使用friend实现数学函数。考虑inv作为成员函数的用法,该函数返回一个rational数字的倒数:
rational rational::inv() const
{
return rational (den, num);
}
可以这么使用inv:
rational x = -3;
rational y = x.inv(); // y equals now 1/x
...
使用friend关键字的实现版本如下:
friend rational inv(const rational & x)
{
return rational(x.den, x.num);
}
实际可这么使用:
rational x = -3;
rational y = inv(x);
...
第二个版本可以看到这与你在很多数学课本里形式一致。因此,这种情况不管什么时候当需要做数学计算里的混合模式计算(例如,矩阵,复数等等),带有隐式类型转换机制的friend实现提供了一个更自然的解决方案。
换句话说,最后一个例子也展示了为什么应该小心使用friend和隐式类型转换。注意到,例如friend函数inv必须知道一个rational包含一个数据成员num,这打破了数据隐藏原则。而且,考虑加入有两个类rational和real,两者都提供了对int的构造函数,下面语句会导致编译错误:
real x = inv(-3); // oops: don’t know which inv to call
rational y = inv(-3); // oops: don’t know which inv to call
这种情形下,编译器不知道inv函数该调用哪个,因为两个类型转换的候选者(real(int) 和rational(int))是同等的,而成员函数版本的实现就不会有问题:
real x = -3;
rational y = -3;
x = x.abs(); // ok: call real::abs()
y = y.abs(); // ok: call rational::abs()
由于friend关键字存在,C++不能被看作是纯面向对象语言。而且,friend让你可以写这样的表达式(y = 3 + x;),且不需要修改已经存在类型的概念,像int。在纯面向对象的系统里,这是无法做到的。As a rule of thumb,我们该谨慎使用friend。后面小节展示其他的控制类型转换的方法。
II-6.2 Explicit type conversions 显式类型转换
我们看到隐式类型转换可能导致意想不到的副作用。C++提供关键字explicit提供一个方法克服那样的问题。考虑下面的例子string类:
class string
{
public:
string(int n); // construct a string of capacity "n"
string(const char *s); // construct a string from "s"
...
};
用户程序:
string s = "hello"; // ok: s = string("hello")
string t = ’a’; // oops: t = int(’a’)
因为存在构造函数string(int),char可以转换为int而不会丢失精度,编译器就在字符'a'上采用了隐式类型转换,这显然不是用户希望的。为了避免这种情况,构造函数需要声明为explicit:
class string
{
public:
explicit string(int n);
string(const char *s);
...
};
用户程序修改如下:
string s = "hello"; // ok: s = string("hello")
string t = ’a’; // error: cannot apply constructor
// implicitly
string u = string(’b’); // ok: explicit constructor
II-7 Statics
前面小节,我们看了类vector以及类rational的例子,这类的每一个对象都可以有一组自己的私有数据。该数据可以被类的自己成员函数访问。
然而还有其他情况:希望在给定一个类的不同对象之间共享一些数据。例如这样一些情形:可能希望以一个缺省的大小初始化vector或者对于rational有共用的常量1和0。第一种情形,这可以让我们为vector提供一个空的构造函数,第二这种情形可以加速计算,因为这些常量不需要每次在使用的时候构建。
II-7.1 Static data members 静态数据成员
C++允许我们透过声明数据成员对于该类所有的对象是static的(全局作用),很优雅地达到上面的目标。考虑类vector:
class vector
{
private:
static int default_sz;
int sz;
int *data;
public:
vector();
// ...
};
int vector::default_sz = 100; // initialization
vector::vector()
{
sz = vector::default_sz;
data = new int[sz];
}
[静态成员的初始化?]
对于类rational的常量0和1的情形,类可以实现如下:
class rational
{
private:
int num, den;
public:
static const rational one, zero;
// ...
};
one和zero被声明为public,公用户使用:
vector v; // vector of size vector::default_sz;
rational x = rational::one;
静态数据成员是前面I-3.1小节我们看到的C静态机制的扩展:提供同样的功能,也封装了类的全局部分。
II-7.2 Static member functions 静态成员函数
除了静态数据,C++允许我们定义静态函数。类似于静态数据概念,变量被类的所有对象共享,而静态函数也应用于类的所有对象上(只能对类的静态数据寻址)。
例如,假设我们想改变类vector缺省初始化大小。可以提供下面的modifier:
class vector
{
private:
// ...
public:
// ...
static void set_default_size(int);
};
void vector::set_default_size(int s)
{
if (s < 1) error("invalid default size");
vector::default_sz = s;
}
用户可以这么用:
vector::set_default_size(150);
vector x; // vector of 150 integers
vector::set_default_size(100);
vector y; // vector of 100 integers
...
和静态数据一样,静态成员函数可以是private也可以是public的。
II-8 Summary 小结
本章我们展示了如何用C++语言基于对象编程范式来实现一些简单的类。
class关键字用于引入新的类型。private和public关键字用于隐藏类型表示法,同时提供用户访问该类接口。对象的初始化清除是透过使用构造函数以及析构函数(如果需要的话)完成。对象可以在同一语句使用赋值运算符经由拷贝构造函数被其他对象初始化。该运算符和其他需要使用的运算符一起需要重载。
不修改对象状态的成员函数称之为accessor。将accessor声明为const是一种良好的编程风格。类似地,const关键字用于表明数据在初始化后不能被成员函数改变。修改对象状态的成员函数称之为modifier。可以透过提供对象引用达成修改;传递对象引用给函数也是避免大对象拷贝的方法。
C++编译器尽可能地会隐式采用类构造函数。在混合算术运算以及结合friend函数利用隐式类型转换特别有用。
最后,使用static关键字可以将数据或函数声明为类的所有对象共用。
II-9 Exercises
1、完成类vector实现。添加sort方法,排序vector元素,根据静态成员direction排序。direction为1升序,-1降序,提供方法set_direction(int)设定排序方向。
2、写一个完整类作一些复杂的算术运算。提供构造函数int,double以及成对的double。重载一元运算符-以及二元运算符+、-、*、/。为实部和虚部提供accessor和modifier。
- 评论列表(网友评论仅供网友表达个人看法,并不表明本站同意其观点或证实其描述)

路过看一下!
吾等屌丝看不懂啊