《高效编程十八式》(3/13)矩阵类:封装与约束

来源:互联网 发布:淘宝pos机突然不能买了 编辑:程序博客网 时间:2024/06/06 23:55
 

矩阵类:封装与约束

王伟冰

    矩阵的元素可以用一个数组来储存,我们希望建立一个矩阵类,来封装各种矩阵操作(比如加减乘、求逆、转置等等)。我们可以这么写:

    class matrix{

        int width,height;//矩阵的宽度和高度

        double* data;//储存矩阵数据的数组

    public:

        matrix(int w1,int h1){//构造函数

            width=w1;

            height=h1;

            data=new double[w1*h1];

        }

        void add(const matrix& m1,const matrix& m2){//加法

            for(int i=0;i<width*height;i++)

                data[i]=m1.data[i]+m2.data[i];

        }

        …… //其它矩阵操作,不一一写出

    };

    在这里我们把width、height和data都设为私有变量,因为我们不希望使用matrix类的代码对这些变量作随意的更改,如果外界代码对类的某个成员变量或函数的随意访问可能导致不好的结果,则应该把它设为私有成员。(安全原则3)随意更改矩阵的高度、宽度或数据的储存位置,都可能导致原有数据的丢失。

    现在我们还不能对矩阵的具体元素进行访问,可以通过在matrix类中重载()运算符来实现:

    double& operator () (int i,int j){//返回第i行第j列元素

        return data[i*width+j];

    }

    由于返回类型是引用类型,所以不仅可以读取,还可以对它进行赋值:

    matrix m(3,3);

    double x=m(0,1); //读取第0行第1列元素

    m(0,1)=x; //设置第0行第1列元素

    这样,我们提供了对矩阵数据的封装,虽然外部代码无法直接访问矩阵数据,但是可以通过()运算符对它进行访问。

 

    现在我们的矩阵类看似可以做很多事情了,但实际上它有很多漏洞。比如,在构造函数里面用new double[w1*h1]分配了内存,但是却没有考虑它的回收,我们可以在matrix类中定义析构函数,让它自动回收:

    ~matrix(){

        delete[] data;

    }

    当一个matrix型变量销毁时(比如说,局部变量在函数返回时会自动销毁),就会调用析构函数~matrix(),释放data指向的内存。

    然而这样还不够,假如我这样用,会发生什么事?

    matrix m1(3,3);

    matrix m2(m1); //调用matrix类的复制构造函数

    m2=m1; //调用matrix类的=运算符

    等等,我们并没有定义什么“复制构造函数”和“=运算符”啊?这是C++为每一个类自动生成的,这两个函数都会自动地复制m1中变量的值到m2里去。上面代码的本意应该是把m1的数据复制给m2,但实际上只是复制data指针的值,也就是说,m1、m2里的data指针指向的是同一个地址,更改任一个矩阵的元素值也会同时更改另一个矩阵的元素值。而且,当这两个matrix对象销毁时,它们的析构函数会分别被调用,执行delete data操作,同一个data地址被delete两次,这样很可能会使程序崩溃。

    所以C++自动生成的这两个函数并不是什么好东西。解决方法是把它们显式地实现为复制数据,而不是复制指针:

    public:

        matrix(const matrix& m1){

            width=m1.width;//复制宽度

            height=m1.height;//复制高度

            data=new double[width*height];//新分配内存

            for(int i=0;i<width*height;i++)

                data[i]=m1.data[i];//复制数据

        }

        void operator = (const matrix& m1){

            delete[] data;//先删除原来的数据所占用的内存

            ……//同上

        }

    所以,复制或传递函数参数时,要清楚复制或传递的是指针(引用)还是数值。(安全原则4)在C#中,对于struct是复制数值,而对于class是复制引用。Java中除了内置类型,所有都是引用。

 

    还有一个漏洞是,访问矩阵元素时可能越界,比如:

    matrix m(3,3);

    m(0,3)=0; //列数越界

    m(-1,0)=0; //行数越界

    我们希望它在越界的时候能够提醒我们,以便我们检查代码中是否有错误:

    double& operator () (int i,int j){

        assert(i>=0 && i<width && j>=0 && j<height);

        return data[i*width+j];

    }

    assert的作用就是,如果传给它的参数为true,那么什么事都没发生,如果为false,那么程序就会终止,或者进入调试状态。(需要包含assert.h头文件)

    所以,要确保每个数组的下标访问不会越界,每个函数的输入参数都合法。(安全原则5)

    void add(const matrix& m1,const matrix& m2)这个函数的输入参数就有可能不合法,要保证两个输入矩阵的宽与高都和当前矩阵一样:

        assert(m1.width==width && m1.height==height

            && m2.width==width && m2.height==height);

    总之,为了防止外部代码随意访问而可能导致的不良后果,我们应当对这些访问加以约束。

 

    再来考虑一下矩阵的转置,比如:

    void transpose(matrix& m1){

        assert(m1.width==height && m1.height==width);

        for(int i=0;i<height;i++)

            for(int j=0;j<width;j++)

                data[i*width+j]=m1.data[j*height+i];//把m1(j,i)复制到 当前矩阵(i,j)里

    }

    把m1的转置矩阵储存到当前矩阵里,这样做没错吧?可是,如果想把一个矩阵的转置储存到它自身(m是n*n矩阵):

    m.transpose(m);

    并不能得到正确的结果。因为在transpose函数里,data和m1.data引用的是同一个地址。考虑m(0,1)和m(1,0),即data[1]和data[n]这两个元素的对调,先是data[1] = data[n],然后再data[n] = data[1],结果两个数都是data[n]原来的值。这就好比交换a和b,不能a=b,b=a,而应该引入一个临时变量:t=a,a=b,b=t。所以这里也要引入一个临时变量:

    matrix m1=m;

    m.transpose(m1);

    所以同时使用两个指针(或引用)时,要考虑两个指针(或引用)引用同一个变量的情况。(安全原则6)这里就是因为data和m1.data引用的是同一个地址。

    于是这个矩阵的使用就多了一个规定:不能将自身对象传入transpose函数。可是让一个矩阵变成自己的转置这是一个很自然的事情,这样子的规定就给人感觉很别扭。这种规定我们把它称为“无理规定”。其实我们只需稍改一下transpose函数:

    void transpose(matrix& m1){

        matrix* pm1=&m1;

        if(&m1==this)//如果m1就是自身,那么就把m1复制一份出来

            pm1=new matrix(m1);

        …… //同上,m1.data改为pm1->data1

        if(&m1==this)//记得删除这个副本

            delete pm1;

    }

    就可以让自身转置了。总之,尽量减少对类和函数的用法的无理规定。(清晰原则3)

    注意,这里transpose函数设计成这样,只是为了引出上面的两条原则,这种设计并不是好的设计,好的设计应该是像m=m1.transpose()这样。

 

    你有没有发现,本节提到的安全原则,基本上都是以牺牲灵活性为代价的,要么是限制访问类的某个成员,要么是限制数组下标或函数参数的范围,要么是限制两个指针不能指向同一变量。

    面向对象程序设计的教科书往往很推崇这种限制,或者叫做“封装”。但是我认为,这种限制有时候是没有必要的。比如我写这个matrix类只是为了给我自己用,我自己很清楚它是怎么实现的,所以我也不会笨到去乱改它的成员变量,不会笨到去传给它一个不合法的参数,那我干嘛还要自己限制自己呢?

    而如果这个matrix类是给别人用的,就如同printf、cout是别人写给我们用的一样,那么封装限制就是必要的了。我们无法保证别人是否会合法地使用这个类,所以必须加以限制。

    所以,对于只在模块内部使用到的代码,访问限制松一些,参数检查松一些,类和变量的命名随意一些。会被外部使用到的代码则相反。(灵活原则6)

    事物总有好处和坏处,对一个类进行封装和约束,虽然麻烦,但是以后你就可以在上层代码放心使用这个类了;不进行封装和约束,你在上层代码就得小心使用,不过只要你自己有把握,就可以更灵活地使用这个类。这就好比C/C++中的指针,其它高级语言都没有指针,因为使用指针很容易出问题;但是如果你指针用得很熟了,你可以用指针实现其它语言无法实现的强大功能。

原创粉丝点击