设计与声明

        C++是一个面向对象(OOP)的语言,继承、封装和多态是其三大特性。所以在我们通过C++设计一个类的时候,需要考虑很多因素,比如重载函数、操作符、控制内存的分配与归还、定义对象的初始化和终结等。设计较好的类有自然地语法、直观的语义,所以如何设计好一个高效的类是我们在编程时需要仔细考虑的问题。

        在设计类之前,Effective C++中将设计类比作是设计type,提供了如下几种考虑点:

1、类的对象应如何被创建和销毁——影响类的构造函数析构函数内存分配函数内存释放函数(包括operator new、operator new[]、operator delete、operator delete[])设计。

2、类的对象在初始化和赋值方面该有什么样的差别——影响类的构造函数重载赋值操作符(operator=)设计。

3、类的对象被以值传递(如做函数形参时)会发生什么——影响类的拷贝构造函数设计。

4、类是否有继承关系——如果类设计继承某些既有的类,就需要考虑是否受virtual或non-virtual函数影响,如果允许其他类继承本类,需要考虑哪些函数需要设计成虚函数,尤其是析构函数是否需要为虚析构函数

5、类中需要定义哪些函数——这个问题的答案将主要影响类的功能,函数根据需求进行定义,以及设计哪些函数是成员函数。

6、类中成员需要被保护的部分——考虑哪些函数和变量应该被设计为private,哪些应该是protected、哪些是public,以及哪些应该是友元函数

7、类中成员变量和成员函数的合法值应该是哪些——对于成员变量而言,通常有一些有效的数值集,成员函数包括构造函数、赋值操作符、setter函数需进行的一些错误检查、异常抛出等。

8、类应该被怎样转换——类和其他类之间是否可以进行隐式转换,如编写operator T1等。若定义了explicit构造函数,就要专门写显式的负责类型转换的函数

9、定义的类是否具有一般化——是否应该设计为模板class template。

        接下来具体指出一些设计类、设计函数接口时需要遵守的几个原则:

一、设计接口时的定义原则:

        在设计类中函数时,也就是设计接口时,应保持一个原则:让接口容易被正确使用,不易被误用。在一个整体的项目中,产品并不是由一整个模块构成的,而是由不同的层次组成,每一个层次之间都通过接口进行调用,每个接口完成自己各自的功能,从而最终扭结在一起行程一整套完整的架构。那么,设计一个好的接口就至关重要。每一个接口的使用者都是客户,所以客户在使用上一层定义的接口时,应该秉持:客户使用的接口如果不能达到预期的行为,那就不应该被编译通过,如果代码能够通过编译,那它的行为就该是客户想要的。

        以一个简单易懂的例子表示,设计一个用来表现日期的class的构造函数:

class Date
{
public:
    Date(int month, int day, int year);
};

        这个接口设计看起来没有不合理的地方,但是,客户在使用时,可能会犯这样的错误:

Date d1(30, 3, 1995);
Date d2(2, 30, 1995);

        第一种因为没有做任何限制,导致使用错误,将月份和日期传反了,第二种是写成2月30日。这两种错误在编译时都不会报错,因为我们设计的接口并没有任何语法上的限制,但在项目中使用时,就会发现得不到预期结果。这就是设计的缺陷之一,不能达到预期结果的接口,就应该被设计为错误使用时不能通过编译。如果改进一步这样设计:

class Date
{
public:
    struct Day
    {
        explicit Day(int d) : val(d) {}
        int val;
    };

    struct Month
    {
        explicit Month(int m) : val(m) {}
        int val;
    };

    struct Year
    {
        explicit Year(int y) : val(y) {}
        int val;
    };
    Date(const Month& m, const Day& d, const Year& year);
};

        这个设计将Day、Month、Year进行了封装,比直接穿三个int好,虽然使用者见名知意,不会传错day、month和year,但因为未设置合法值,比如月份,有些系统中是从0开始表示1月,有些是从1开始,依然还是可能传入错误但却编译通过。所以,最好的设计法就是将有效地值直接规定好,使调用者直接使用规定的值进行调用,比如月份:

class Month
{
public:
    static Month Jan() {return Month(1); }
    static Month Feb() {return Month(2); }
    //...

private:
    explicit Month(int m);
};

        调用者这样调用就不可能出现与预期不符合的错误了:

 Date d(Month::Feb(), Day(30), Year(1995));

        在设计接口时,为了能够让接口更容易被正确使用,接口设计一般都要具有行为一致性。举例来说,就像C++ STL容器,无论vector、set、map等容器,都有一个名为size的接口,它会告诉调用者目前容器内有多少个对象,这就是行为一致性,假设换成一个容器使用size、另一个容器使用length、再一个容器使用count,则对调用者来说,将很不容易使用。另一方面,我们设计接口时,不能乞求想让调用者去做什么事,假设我们定义的接口需要让调用者自己最终使用完毕删除指针,就容易造成调用者忘记删除指针或者删除指针超过一次这样的错误,比如说,简简单单返回指针的接口就可以使用智能指针的方式代替,调用者就不需要关注指针的析构问题。多线程情形下的互斥锁使用,调用者只需要在该加锁的地方直接调用加锁接口,在接口执行完毕时自动释放锁就可以,而不需要手动释放。也许我们认为只要把接口声明好后,注释详细写清楚就好了,但在大型项目中,接口的调用者和维护者不一定固定,时间久了势必会有人更改流程或更改调用方法,不可能每个人都清楚每一个接口的注意事项的,所以在设计时,就注意到这一点是非常必要的。

二、设计接口传递参数时的原则:

        在设计一些需要传递函数对象的接口中,C++默认都是以传递值的方式进行的,也就是说,函数参数都是按照实际参数的复件为初值,调用端所获得的都是函数返回值的一个复件。这些复件都是通过调用对象的拷贝构造函数产生的,所以在传递对象时,我们最好使用传递引用的方式进行

#include <iostream>

using namespace std;

class Person
{
public:
    Person()
    {
        cout << "Person Constructor" << endl;
    }
    ~Person()
    {
        cout << "Person Descontructor" << endl;
    }

    Person(const Person& p)
    {
        this->name = p.name;
        this->address = p.address;
        cout << "Person copy Constructor" << endl;
    }
    
    void behavior() const
    {
        cout << "Person behavior" << endl;
    }

private:
    std::string name;
    std::string address;
};

class Student: public Person
{
public:
    Student()
    {
        cout << "Student Constructor" << endl;
    }
    ~Student()
    {
        cout << "Student Descontructor" << endl;
    }
    Student(const Student& p)
    {
        this->schoolName = p.schoolName;
        this->schoolAddress = p.schoolAddress;
        cout << "Student copy Constructor" << endl;
    }
    void behavior() const
    {
        cout << "Student behavior" << endl;
    }
    
private:
    std::string schoolName;
    std::string schoolAddress;
};

//接口
bool validateStudent(Student s)
{
    cout << "validate s is a Student" << endl;
    return true;
}

//接口
void printBehavior(Person p)
{
    p.behavior();
}

int main()
{
    Student xiaoming;
    cout << "-----" << endl;
    bool ret = validateStudent(xiaoming);
    printBehavior(xiaoming);
    return 0;
}

        这个例子中,validateStudent接口需要的是Student对象,也就是按值传递的,那么调用它时,Student由于是继承自Person类,所以调用了Person、Student构造函数,结束时,又调用了Student、Person的析构函数,同时,类中还带有两个string类的成员,所以传递时,也同时调用了四次string的构造函数和四次string的析构函数,这只是一个非常小的类,可见在大型项目中,类众多,继承关系复杂,成员变量众多,如果接口都是以传值的方式进行,就会造成大量的构造和析构函数被调用,效率是很低的。还有一点,在Person类和Student中,都定义了一个接口叫behavior,但是在使用这个接口时,printBehavior接口接收的是一个父类的对象的值,当我们给它传递子类的对象值时,可以看出打印的结果是父类的behavior而非子类的behavior,也就是说,对象被切割了,子类对象的特性被切割了,只保留了父类的特性,出现这两种问题的根因就是传值导致的,所以这种时候,接口使用传递引用的方式,可以大大减少这种不必要的动作:

//接口
bool validateStudent(const Student& s)
{
    cout << "validate s is a Student" << endl;
    return true;
}

//接口
void printBehavior(const Person& p)
{
    p.behavior();
}

int main()
{
    Student xiaoming;
    cout << "-----" << endl;
    bool ret = validateStudent(xiaoming);
    printBehavior(xiaoming);
    return 0;
}

//result:
Person Constructor
Student Constructor
-----
validate s is a Student
Student behavior
Student Descontructor
Person Descontructor

        传递引用不仅没有调用构造函数和析构函数,也没有造成对象切割问题,传入子类的对象引用,得到的就是子类的行为,而不是父类的,因为传递引用就是传递的自身,并不会做一个复件出来。但是这种传递引用的方式虽然很高效,但一般不适用于内置类型和STL的迭代器,所以在定义要传递内置类型和STL容器迭代器类型的接口时,直接传递值更高效,对于其他用户自定义类型,均使用传引用方式传递。

三、设计接口返回值的原则:

        虽然函数参数中有用户自定义对象时,传递引用的方式更高效,但并不代表在需要返回一个对象的时候,返回一个引用也高效。就像下面这个例子:

#include <iostream>
using namespace std;

class Rational
{
public:
    Rational(int numerator = 0, int denominator = 1) 
    {
        n = numerator;
        d = denominator;
    }
    //...
private:
    int n, d;

    friend const Rational operator*(const Rational& lhs, const Rational& rhs);

    friend ostream& operator<<(ostream& out, const Rational& r);
};

ostream& operator<<(ostream& out, const Rational& r)
{
    out << r.n << "/" << r.d << endl;
    return out;
}

const Rational operator*(const Rational& lhs, const Rational& rhs)
{
    Rational result(lhs.n * rhs.n, lhs.d * rhs.d);
    return result;
}

int main()
{
    Rational x(1, 2);    //1/2
    Rational y(3, 5);    //3/5
    Rational w = x * y;  //3/10
    cout << w << endl;

    return 0;
}

//result:
3/10

        Rational类用来表示分数,其中重载了operator*用来计算两个分数对象相乘的结果,从接口的声明中,可以看出我们需要返回的是一个Rational对象值,那是否可以将这个返回的对象值更改为Rational对象的引用呢?答案是否定的,首先看这种方式:

const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
    Rational result(lhs.n * rhs.n, lhs.d * rhs.d);
    return result;
}

        这种方式尝试返回一个引用,但这么写,就等于是在栈上分配了一个result对象,但随着operator*函数的结束,栈上的空间会被析构,那么这个函数内分配出来的对象就也会被析构,从而造成返回的是一个已经被析构的对象,这会造成程序挂死等问题出现。再看这种方式:

const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
    Rational *result = new Rational(lhs.n * rhs.n, lhs.d * rhs.d);
    return *result;
}

        这种方式也尝试返回一个引用,函数中在堆上分配了一块不会随着函数执行结束而被析构的内存,这么做可以得出正确的结果,但是函数内分配的内存就无法释放了,造成了内存泄漏,所以,这么做也是不行的。再或者这样做:

const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
    static Rational result(lhs.n * rhs.n, lhs.d * rhs.d);
    return result;
}

int main()
{
    Rational x(1, 2);    //1/2
    Rational y(3, 5);    //3/5
    Rational a(3, 2);    //1/2
    Rational b(3, 5);    //3/5
    Rational w = x * y;  //3/10
    Rational z = a * b;  //9/10
    if(w == z)
    {
        cout << "w == z" << endl;
    }
    cout << w << endl;
    cout << z << endl;

    return 0;
}

//result:
w == z
3/10

3/10

        这种返回引用的方式考虑到了前面定义在栈上和堆上的不合理之处,将对象定义成了static类型,但这样做,正如main中所做的比较一样,当两个不相等的Rational去做==操作时,发现结果总是相等,这是因为operator==是将operator*内的static Rational对象值和operator*内的static Rational对象值进行比较,结果当然永远相等。再有,如果改成static Rational[]数组可以解决问题,但那样做程序的效率也将大大下降,为了避免一个返回对象值而硬生生做成以static数组返回一个引用的方式得不偿失,并且还有一个问题是引用其实只是一个别称,我们使用引用时一般都是等于使用某个对象自身,所以返回一个引用的话,就要有一个对象自身去接收它,像上面的例子,两个对象相乘,我们使用一个本身就是a*b的对象去接收a*b的引用本来也是矛盾的,所以在需要返回一个对象的时候非要去返回一个引用,这是不合理的

四、设计类成员变量时的封装原则:        

        在设计类的成员变量时,要把它们的访问权限定义成private。这样可以提供给调用者访问数据的一致性,并能细化数据访问的权限,并且类提供更大的可扩展性。首先来说,如果把成员变量都设置为public,调用者就可以直接调用成员变量,在使用上来说,这需要调用者记住调用时哪些是变量哪些是函数,这不利于前面所说的接口一致性,如果变量都是private,则调用者只需要记住调用的都是函数接口而非变量。对于类设计者而言,如果变量都声明为private,只提供以接口形式来更改或者读取变量的话,这可以让设计者对类的成员变量处理有更精确的控制,比如将变量划分成“不准访问”、“只读访问”、“读写访问”、“只写访问”:

class Levels
{
public:
    int getReadOnly() const {return readOnly; }
    void setReadWrite(int value) {readWrite = value; }
    int getReadWrite() const { return readWrite; }
    void setWriteOnly(int value) {writeOnly = value; }  
private:
    int noAccess;
    int readOnly;
    int readWrite;
    int writeOnly;
};

        还有一个把成员变量声明为private对调用者而言最有利的地方就是封装效应。当我们把成员变量private之后,调用者无法调用到这些成员变量,而只能调用开放的接口,这样一旦后期类的设计有所更改,比如某个变量不再使用而删除了,对于调用者来说,并不要做任何改变,因为本来也没有直接调用过那个变量。这样,设计者只需要“暗箱操作”,对于调用者而言是不可见的,这样一来,就不会对整个项目的代码造成任何影响。如果声明为protected,调用者依然还是可以调用到变量,从封装的角度来看,其实只有两种访问权限:private(封装)和其他(不提供封装)。所以,声明成员变量为private是一个很有用的原则。

五、设计类成员函数时,多考虑其合理性:

         拿前面举过的Rational类的例子来说,计算两个Rational的乘法重载了operator*函数,使用的是非成员函数,那这里为什么要设计成非成员函数而非成员函数呢?如果写成成员函数,则是以下这样的情形:

class Rational
{
public:
    Rational(int numerator = 0, int denominator = 1) 
    {
        n = numerator;
        d = denominator;
    }

    const Rational operator*(const Rational& rhs) const
    {
        return Rational(this->n * rhs.n, this->d * rhs.d);
    }

    //...

private:
    int n, d;

    friend ostream& operator<<(ostream& out, const Rational& r);
};

int main()
{
    Rational x(1, 2);    //1/2
    Rational y(3, 5);    //3/5
    Rational w = x * y;  //3/10
    Rational v = w * 3;  //9/10
    Rational v1 = 3 * w; //编译不通过
    
    cout << w << endl;
    cout << v << endl;

    return 0;
}

        上述代码为什么w*3就可以编译通过并运行出正确结果,3*w就不可以?因为w*3发生了隐式类型转换,因为Rational的构造函数前并未声明explicit,所以编译器会这样做隐式类型转换:

const Rational temp(3);
result = w * temp;

        但将3写在w前面,编译器就不知道3要被转换成什么类型,它只会认为3是个int,却又找不到这样的构造函数,也就不会编译通过了,但如果在Rational的构造函数前加explicit声明,则无论w*3还是3*w都将不会发生隐式类型转换,都不会编译通过。所以,要想让3*w这种写法也能编译通过并运行处正确的结果,就要把operator*的实现变成非成员函数,但operator*中访问到了Rational类中的私有变量,我们可以将他做成友元函数,使他可以访问类Rational中的私有变量,但这样做,又破坏了上一原则中提到的封装性,所以更好的做法是提供:

int Rational::numerator() const {return numerator;}
int Rational::denominator() const {return denominator;}

        然后在const Rational operator*(const Rational& lhs, const Rational& rhs)非成员函数中,使用这两个接口操作私有成员变量相乘,如果这个非成员函数和class Rational不在同一个文件内,则在另一个文件内包含class Rational的头文件即可。

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值