C++中避免返回指向对象内部的句柄(handles)

简介: C++中避免返回指向对象内部的句柄(handles)

1.问题的引入


假如你正在给一个应用写一个矩形类,这个矩形由左上角和右下角的顶点坐标表示。为了表示这两个点,我们写一个表示点的类


class Point{
public:
    Point(int x, int y);
    void setX(int newVal);
    void setY(int newVal);
    // ....
};


为了让矩形对象的体积小一点,我们将这两个顶点装在另一个结构体中,并用指针指向它:


struct RectData{
    Point ulhc;  // 左上角
    Point lrhc;  // 右下角
};
class Rectangle{
    // ...
private:
    std::shared_ptr<RectData> pData;  
};


由于用户想要得到点的坐标,所以需要让矩形类要提供返回这两个点的函数。因为矩形类是我们自定义的类,根据之前的文章C++中多用引用传递方式替换值传递方式中提到的对于自定义的类,传递引用方式比传值方式更高效,所以我们让这两个函数返回引用:


class Rectangle{
public:
    // ...
    Point& upperLeft() const {
        return pData->ulhc;
    }
    Point& lowerRight() const {
        return pData->lrhc;
    }
private:
    std::shared_ptr<RectData> pData;  
};


上面的代码虽然可以通过编译,但却是自我矛盾的!首先,函数upperLeft()和函数lowerRight()被声明为const成员函数。因为它们的目的是为了只返回一个对象而别的什么都不做,但两个函数却都返回了指向私有成员的引用,因此调用者就能通过这个引用来改变对象


Point coord1(0, 0);
Point coord2(100, 100);
const Rectangle rec(coord1, coord2);  // 我们希望它是常对象rec
rec.upperLeft().setX(50);  // upperLeft()的调用者rec能够使用被返回的指向rec内部的Point成员变量的引用来更改成员
// 但是,rec实际上是不可变的,因为它是常对象


2.得到的结论



从上面的代码段中,我们可以得到下面两个教训:

(1).数据成员的最好封装性取决于最能破坏封装的函数。虽然ulhc和lrhc两个点都声明为private,但由于存在两个返回引用的函数的存在,它们其实相当于是public。因为public函数upperLeft()和lowerLeft()传出了它们的引用。


(2).如果一个函数返回了指向储存在对象外部的数据成员的引用,即使这个函数声明为了const,这个函数的调用者也能修改这个成员。原因见之前的文章尽可能使用const修饰符中的bitwise constness的局限性


除了引用,返回指针和迭代器也是相同的结果,也是由于相同的原因导致。引用、指针、迭代器都是本文标题中所说的"句柄"(handle),即接触对象的某种方式。直接返回句柄总会带来破坏封装的风险,这也导致声明为const的函数并不是真正的const。


注意:"内部成员"除了内部数据还包括内部函数,即声明为私有(private)或保护(protected)的函数。因此,对于内部函数也是一样,也不要返回它们的句柄,否则用户也可以通过返回的函数指针来调用它们,这样私有的成员函数也相当于变成了公有。


3.问题的解决方法


回到上面出现自我矛盾的代码段,如果要解决返回引用会导致数据成员被改变的问题,只需要给函数的返回类型加上一个const。如下面的代码段所示:


class Rectangle{
public:
    // ...
    // 现在返回的是const Point&  
    const Point& upperLeft(){
        return pData->ulhc;
    }
    const Point& lowerRight(){
        return pData->lrhc;
    }
private:
    std::shared_ptr<RectData> pData;  
};


这样用户就只能对其进行读操作而不能进行写操作了,给函数声明的const也就不会骗人了。至于封装性问题,让用户知道这个矩形的位置是完全合情合理的,所以我们给封装提供了有限的放宽,让用户可以读到私有数据,但坚决不能让用户执行写操作。


然而即使这样,返回的句柄仍然会导致一个问题:"野句柄"(dangling handle),即这个句柄指向的对象不存在。最常见的场景是函数返回值,假如我们正在给某个GUI对象写一个返回它边界框的函数,返回类型是Rectangle。如下面的代码段所示:


class GUIObject{
    // ...
};
const Rectangle boundingBox(const GUIObject& obj);


现在,客户可能会像下面那样使用这个函数:


GUIObject* pgo;
// ...
const Point* pUpperLeft = &(boundingBox(*pgo).upperLeft());


现在有意思的事发生了,取址运算符括号里面的函数boundingBox()会返回一个新的临时Rectangle对象称为temp。有了这个临时对象之后,我们就可以获得指向它左上角的Point对象,然后pUpperLeft自然就获得了这个Point对象的地址。但是,temp毕竟是临时对象。在这行代码执行完后,temp会被销毁,它所包含的Point对象也会被销毁。最后,pUpperLeft存储了一个指向不存在的对象的指针。


因此,这也解释了为什么返回指向内部成员"句柄"的函数是危险的,不管你的"句柄"是指针、引用还是迭代器;不管你的函数返回值是不是const、你的函数是不是const。但是,这不代表要杜绝这种做法,有时候不得不这样做。例如索引[]操作符,用来获取容器(比如std::vector)中的某个对象,它返回的是指向容器中的数据的引用,来让你完成写操作。记住,在我们自己设计的程序中还是尽量避免不要这么做。


4.总结


(1) 避免返回指向内部成员的"句柄"(包括指针,引用,迭代器)。不返回"句柄"能增强封装性,让const函数成为真正的const,也能减少"野句柄"。

相关文章
|
1月前
|
编译器 C++
C++之类与对象(完结撒花篇)(上)
C++之类与对象(完结撒花篇)(上)
36 0
|
14天前
|
存储 编译器 C++
【c++】类和对象(中)(构造函数、析构函数、拷贝构造、赋值重载)
本文深入探讨了C++类的默认成员函数,包括构造函数、析构函数、拷贝构造函数和赋值重载。构造函数用于对象的初始化,析构函数用于对象销毁时的资源清理,拷贝构造函数用于对象的拷贝,赋值重载用于已存在对象的赋值。文章详细介绍了每个函数的特点、使用方法及注意事项,并提供了代码示例。这些默认成员函数确保了资源的正确管理和对象状态的维护。
46 4
|
15天前
|
存储 编译器 Linux
【c++】类和对象(上)(类的定义格式、访问限定符、类域、类的实例化、对象的内存大小、this指针)
本文介绍了C++中的类和对象,包括类的概念、定义格式、访问限定符、类域、对象的创建及内存大小、以及this指针。通过示例代码详细解释了类的定义、成员函数和成员变量的作用,以及如何使用访问限定符控制成员的访问权限。此外,还讨论了对象的内存分配规则和this指针的使用场景,帮助读者深入理解面向对象编程的核心概念。
43 4
|
1月前
|
存储 编译器 对象存储
【C++打怪之路Lv5】-- 类和对象(下)
【C++打怪之路Lv5】-- 类和对象(下)
28 4
|
1月前
|
编译器 C语言 C++
【C++打怪之路Lv4】-- 类和对象(中)
【C++打怪之路Lv4】-- 类和对象(中)
25 4
|
1月前
|
存储 编译器 C++
【C++类和对象(下)】——我与C++的不解之缘(五)
【C++类和对象(下)】——我与C++的不解之缘(五)
|
1月前
|
编译器 C++
【C++类和对象(中)】—— 我与C++的不解之缘(四)
【C++类和对象(中)】—— 我与C++的不解之缘(四)
|
1月前
|
C++
C++番外篇——对于继承中子类与父类对象同时定义其析构顺序的探究
C++番外篇——对于继承中子类与父类对象同时定义其析构顺序的探究
54 1
|
1月前
|
编译器 C语言 C++
C++入门4——类与对象3-1(构造函数的类型转换和友元详解)
C++入门4——类与对象3-1(构造函数的类型转换和友元详解)
20 1
|
1月前
|
存储 编译器 C语言
【C++打怪之路Lv3】-- 类和对象(上)
【C++打怪之路Lv3】-- 类和对象(上)
17 0