面向对象程序设计和泛型程序设计是C++的两大特色,它们分别通过类和模板来实现。说实话,对于像我这样编写小程序的人来说,这些功能基本用不上,面向过程的C足矣。但是,毕竟在学C++,出于兴趣,就设计编写了一个矩阵类,算是对类和模板的一次综合运用。我一贯主张探索性和应用性的学习,在编写这个矩阵类之前,对于类和模板只知其概念,具体细节一概不知,所以编写编译过程中各种报错,而我则是各种纠结、迷茫和疑惑,最终在谷歌、百度和《C++ Primer》的帮助下勉强完成,这可能就是孔夫子所说的“不愤不启,不悱不发”吧!下面叙述一下实现过程和切身感受。
先说一下这个矩阵类要实现的功能。①动态分配内存,也就是说矩阵的大小可以在程序执行时确定,编译时无需指定。在C/C++中这叫动态数组,并非什么特色。②矩阵元素通过中括号方式索引,即 M[i][j]。③支持算符重载。④使用模板,矩阵的元素可以是任意数据类型,当然考虑到数学上的应用,矩阵的元素应该是double、single、int 和long等。
如同所有的算法都有核心一样,一个类也有其核心,说白了核心实现了类的功能,而其他细枝末节的部分不过是构造一个友好的接口,方便使用而已。在叙述该矩阵类的的具体实现之前,有必要讲一下它的核心。矩阵是二维数组,在C/C++中可通过多种方式创建一个二维数组 ,如果要求动态的话,可能就要用到new/delete了。最常用的方式是分两步实现,先创建一个指针数组,再分别为该指针数组中各指针分配内存以存放元素。例如我要创建(m,n)的二维数组,可以通过下面的方式实现:
double** p = new double* [m];
for (unsigned i = 0; i != m; ++i) {
p[i] = new double [n];
}
在使用过之后需要释放内存,同样需要两步来完成:
for(unsigned i = 0; i != m; ++i) {
delete [] p[i];
}
delete [] p;
感觉有点麻烦,的确,下面这种方法显得更简洁。我们可以分配一个 m×n 大小的一维数组,然后每 n 个相邻元素作为一个小数组,用一个指针指向它的首元素,这样的指针需要 m 个,我们用一个有 m 个元素的指针数组存储它们。代码如下:
double** p = new double* [m];
double* tmp = new double [ m*n ]
for (unsigned i = 0; i != m; ++i) {
p[i] = &(tmp[n*i]);
}
释放内存时尤其方便:
delete [] *p; (或者写成:delete [] p[0],可能这样更容易理解些)
delete [] p;
你看明白了吗?p是二维指针,*p是一维指针,它与 p[0] 等价,是指向第一个小数组首元素的指针,当然也是指向整个 m×n 数组首元素的指针,它与 tmp 也等价!这就是为什么可以用一句话替代循环的原因!
这里有必要提一下,可能有的人会说还有一种方式动态创建二维数组:
double (*p)[n] = new double [m][n];
delete p;
看上去确实更简洁,而且也能创建二维数组,但是它并没有完全做到动态创建。对于第一维 m 可以是常量或变量,而第二维 n 则必需是常量(要么是字面常量,要么是const常量),所以它只能算是一种半动态二维数组。
鉴于以上分析,我使用第二种方式创建矩阵类。还有一个问题就是矩阵元素的类型,虽然说多数情况下在进行数学运算时首选double,但也不排除有使用single甚至int的可能,所以在创建这个类时使用了模板。C/C++允许分别编译然后连接,并且多数情况下也推荐这样做。如果大家已经养成了将类的定义和实现分别放在头文件和源文件中的好习惯的话,可能在使用模板时会排错排到抓狂,该死的“undefined reference”永远挥之不去。所以这里特别提醒,使用模板的话一定要将类的定义和实现全部放在头文件中,g++编译和连接模板时既需要声明又需要定义,这叫“包含模板编译模式”,Cfront支持“分离模板编译模式”,两种编译模式各有优缺点。像这样的问题很多,比如“非静态成员函数不能做默认实参”,“赋值算符重载函数和类拷贝成员函数可由编译器自动生成”等等,从编译原理上都很容易理解,但如果一点编译原理都不懂的话可能会被C/C++的各种规则搞疯!
由于这不是一篇专业的讲C++面向对象编程和泛型编程的文章,所以也不准备把创建一个完整的矩阵类全部写下来,那样也显得冗长和重复。比如,对于算符重载时讲 “=”,“+=” 和 “+” 就够了,你会看到他们之间的联系,其余的大同小异,不再赘述。
首先是类的定义,这里尽量以极精简的知识涵盖尽可能多的语法和C++语言风格。
template < typename T >
// 但是当你 要多次用到相同的代码,写成函数通常是个好的选择。
// 写的话函数的形参一定要是 const 引用,
//非 const 引用在某些情况下会出错。
// 非成员函数,注意它是在类外声明的。
以上是类的定义,基本上只包括了数据和成员函数的声明,极少数很简单的成员函数以定义的方式出现在类定义中,这是一种好的习惯。类的定义只需要函数的声明,函数的定义可以放在类外;当然也可以放在类定义中,它们默认为内联函数,所以除非某个函数非常简单,否则尽量把函数定义放在类外。上面的代码提供了详细的注释,然而有两点还是有必要在这里另行解释的:① const 成员函数,② const 引用形参。
首先我们来看 getRows 这个成员函数,它的声明是 unsigned getRows() const; 最后的 const 表示它是一个 const 成员函数,即调用它时并不改变对象的任何数据。可能很多人会觉得这个 const 很没必要,因为函数体就只有一个语句(return m_row;),而且这个语句并没有改变对象的数据,但是编译器并不能确定你在函数体中是否会更改对象的数据,它强制要求你用 const 来指定。什么意思呢?就拿输出重载函数(ostream& operator <<(ostream& os, const matrix& mat);)来说,第二个形参被声明为 const 对象的引用(const matrix&),也就是说在函数体中只读取该 matrix 对象的数据而不改变它们。编译器如何保证你不改变这些数据呢?通过设置访问权限来实现,即在函数中只允许访问 const 成员函数。例如,我要在 ostream& operator <<(ostream& os, const matrix& mat); 中调用 getRows 函数,该函数就必需被声明为 const 成员函数(unsigned getRows() const;),如果声明成非 const 成员函数(unsigned getRows();)则会报错。C++ 很严谨,你必需严格指明哪些数据允许被访问或是更改,如果说别的语言通过人为约定来控制对数据的访问,C++ 则是通过编译器的强制规定。
另一点需要强调的是 const 引用形参,我们拿赋值算符重载函数(matrix& operator =(const matrix& mat);)来说明这个问题。形参被声明为 const 对象的引用(const matrix&),直接声明为非 const 引用(matrix&)可不可以呢?初看也没什么错,比如我使用这个赋值算符重载:A = B,将 B 矩阵的值赋给 A (相当于调用了 A.operator(B)),B 以非 const 引用的方式传递参数,没什么不妥。但是,如果我做加法运算后赋值:C = A + B,错误就出现了。对于这样的语句编译器是通过两步来完成的:
tmp = A + B; // 调用 “+” 算符重载函数(matrix operator +(const matrix& mat1, const matrix& mat2);)
C = tmp; // 调用 “=” 算符重载函数(matrix& operator =(const matrix& mat);)
可以看到编译器先创建了一个 tmp 变量来存储 A + B 的计算结果,而这个 tmp 变量其实是 const 类型的,它作为实参传给赋值算符重载函数(matrix& operator =(const matrix& mat);)要求该函数的形参必需是 const 引用类型,否则就会报错。在这里可以对函数通过 const 引用传参做一个小结,① const 和 非 const 类型均可作为实参传给 const 引用形参,因为 const 引用形参接受的实参是常量,非 const 类型是变量,也可以作为常量使用;② 只有非 const 实参才能传递给非 const 引用形参,const 实参则不能传递给非 const 引用形参,因为非 const 引用形参接受的实参是变量,而 const 实参的数据是常量,值不允许更改。一句话,变量可以作为常量使用,而常量不能作为变量使用!
我认为文章的重点到此就应该结束了,函数的定义都是相当简单的,为保证完整性,下面也给出代码和简单的说明。函数模板与类模板基本相同,而在类外定义的成员函数别忘了加上类作用域说明符。
// two private methods: initialize and demension
matrix& matrix<T>::operator =(const matrix& mat) // 赋值算符重载必需作为成员函数,
//并且返回对该对象的引用。
评论