C++继承关系中特殊成员函数的注意事项

所谓特殊成员函数就是指在某些条件下,编译器会自动生成的一些成员函数:

  • 默认构造函数
  • 复制构造函数
  • 析构函数
  • 赋值运算符重载函数
  • 移动构造函数(C++11及之后)
  • 移动赋值运算符重载函数(C++11及之后)

规则

至于什么条件会自动生成,参见:Effective Modern Cpp
,反正我是记不太住,不过我觉得这不是重要的,最重要的是在继承关系时,实现这些特殊成员函数需要注意的事项:

No. 特殊成员函数 实现注意事项 原因
1 构造函数 子类构造函数必须显示父类构造函数 若子类未显式调用父类的拷贝/移动构造函数,父类部分会调用默认构造函数而非拷贝/移动构造函数。
2 析构函数 父类析构函数应为 virtual 否则多态时,可能未调用子类析构函数,导致内存泄漏。
3 拷贝/移动操作 若子类自定义这些操作,必须显式调用父类的对应操作 否则父类部分可能未正确处理。

破坏规则的后果

⛔ 破坏规则1:构造函数中未显式调用父类构造函数

如果构造函数中未显式调用父类构造函数,来看如下代码:

#include <cstring>
#include <iostream>

class Shape
{
public:
    explicit Shape(const char *n = "default name")
    {
        std::cout << "默认构造 Shape" << std::endl;
        name = new char[strlen(n) + 1];
        strcpy(name, n);
    }

    ~Shape()
    {
        std::cout << "析构 Shape" << std::endl;
        delete[] name;
    }

    const char *getName() const
    {
        return name;
    }

private:
    char *name = nullptr;
};

class Rectangle : public Shape
{
public:
    //<=== 存在问题,未将参数传递给父类默认构造函数
    explicit Rectangle(const char *sn = "default sub name", const char *n = "default name")
    {
        std::cout << "默认构造 Rectangle" << std::endl;
        subName = new char[strlen(sn) + 1];
        strcpy(subName, sn);
    }

    ~Rectangle()
    {
        std::cout << "析构 Rectangle" << std::endl;
    }

    const char *getSubName() const
    {
        return subName;
    }

private:
    char *subName = nullptr;
};

int main(int argc, char *argv[])
{
    std::cout << "--------开始默认构造t1----------" << std::endl;
    Rectangle t1("矩形", "四边形");
    std::cout << "--------结束默认构造t1-------------" << std::endl;
    std::cout << "t1 name:" << t1.getName() << " subName:" << t1.getSubName() << std::endl;

    return 0;
}

本意是创建Rectangle对象t1,分别传递子类和父类的名字,而实际运行结果为:

--------开始默认构造t1----------
默认构造 Shape
默认构造 Rectangle
--------结束默认构造t1-------------
t1 name:default name subName:矩形
析构 Rectangle
析构 Shape

这个问题是显而易见的,子类的默认构造函数未显式调用父类的默认构造函数,把参数传递过去,导致父类成员变量name未按预期初始化,而是使用的默认值。修正这个问题也很简单:

explicit Rectangle(const char *sn = "default sub name", const char *n = "default name")
    : Shape(n) //<===初始化列表里面显示调用下父类默认构造函数
{
    std::cout << "默认构造 Rectangle" << std::endl;
    subName = new char[strlen(sn) + 1];
    strcpy(subName, sn);
}

📢 这里有一个注意事项,Shape(n)必须写在初始化列表里面,如果在函数体内写:

explicit Rectangle(const char *sn = "default sub name", const char *n = "default name")
{
    std::cout << "默认构造 Rectangle" << std::endl;
    Shape(n);
    subName = new char[strlen(sn) + 1];
    strcpy(subName, sn);
}

这里是直接编译报错的,报错为:

error: declaration of ‘Shape n’ shadows a parameter

意思是声明的变量Shape n,掩盖了一个参数。什么意思呢?实际在函数体内Shape(n)就相当于Shape n
,这里括号根本不是函数调用,传参的意思,而是修改运算优先级的意思,并且是冗余的括号,啥是修改运算优先级?

int a, b, c, d;
a = (b + c) * d;

那就是Shape n的意思,也就是参数中有一个名为n的参数,而这里又定义了一个Shape类型的局部变量n,导致参数中的n被掩盖,这就是上面报错的含义。

再看拷贝构造函数的例子,分别在父类Shape和子类Rectangle增加各自的拷贝构造函数:

//基类Shape中
Shape(const Shape &other)
{
    std::cout << "拷贝构造 Shape" << std::endl;
    delete[] name;
    name = new char[strlen(other.name) + 1];
    strcpy(name, other.name);
}

//子类Rectangle中
Rectangle(const Rectangle &other) //<=== 存在问题,未显式指定调用父类拷贝构造函数,而是被编译器默认为调用默认构造函数,导致父类部分的数据未正确拷贝。
{
    std::cout << "拷贝构造 Rectangle" << std::endl;
    delete[] subName;
    subName = new char[strlen(other.subName) + 1];
    strcpy(subName, other.subName);
}

//main函数中
std::cout << "--------开始拷贝构造t2----------" << std::endl;
Rectangle t2(t1);
std::cout << "--------结束拷贝构造t2-------------" << std::endl;
std::cout << "t2 name:" << t2.getName() << " subName:" << t2.getSubName() << std::endl;

编译运行:

--------开始默认构造t1----------
默认构造 Shape
默认构造 Rectangle
--------结束默认构造t1-------------
t1 name:四边形 subName:矩形
--------开始拷贝构造t2----------
默认构造 Shape
拷贝构造 Rectangle
--------结束拷贝构造t2-------------
t2 name:default name subName:矩形
析构 Rectangle
析构 Shape
析构 Rectangle
析构 Shape

可以看到t2的拷贝构造过程,先调用的子类拷贝构造函数,由于子类拷贝构造函数未显式调用父类构造函数,那么默认就会是调用默认构造函数。于是t2的父类部分没按照预期进行拷贝构造,name被设置为了默认构造函数的默认值。修正的方法就是在子类构造函数参数列表里面显示调用父类拷贝构造函数。

Rectangle(const Rectangle &other)
    : Shape(other)
{
    std::cout << "拷贝构造 Rectangle" << std::endl;
    delete[] subName;
    subName = new char[strlen(other.subName) + 1];
    strcpy(subName, other.subName);
}

同理,移动构造函数也是要注意这点。

Rectangle(Rectangle &&other)
    : Shape(std::move(other))
{
    std::cout << "移动构造 Rectangle" << std::endl;
    subName = other.subName;
    other.subName = nullptr;
}

规律:如果子类构造函数未显示调用父类构造函数,默认调用的是默认构造函数

C++遵循“程序员明确控制”的设计哲学。若希望基类以非默认方式初始化(如拷贝构造),必须由程序员显式指定。这避免了隐式操作可能引发的意外开销或逻辑错误,符合“不为不需要的功能付出代价”的原则。

⛔ 破坏规则2:父类析构函数不为virtual

还是上面的例子,注意子类和父类析构都没声明为虚函数:

~Shape()
{
    std::cout << "析构 Shape" << std::endl;
    delete[] name;
}

~Rectangle()
{
    std::cout << "析构 Rectangle" << std::endl;
}

main函数执行如下代码:

Shape *ps = new Rectangle();
delete ps;

运行结果为:

默认构造 Shape
默认构造 Rectangle
析构 Shape

可以看到仅父类析构函数调用了,子类的析构函数未调用,从而导致子类的subName
内存泄漏。这个其实很好理解,由于析构函数未声明为虚函数,因此析构函数不在虚表中,不能借助多态机制调用预期的子类的虚函数,再由子类虚函数自动调用父类虚函数。修正方法很简单,父类析构函数前面加一个virtual关键字。

记住一条规则:如果类会被作为父类使用,请给析构函数加上virtual

⛔ 破坏规则3:若子类自定义了赋值/移动操作符,必须显式调用父类的对应操作符

基于上面的代码,添加赋值操作符重载函数:

Shape &operator=(const Shape &other)
{
    std::cout << "赋值 Shape" << std::endl;
    if (this == &other)
    {
        return *this;
    }
    delete[] name;
    name = new char[strlen(other.name) + 1];
    strcpy(name, other.name);
    return *this;
}
    
Rectangle &operator=(const Rectangle &other)
{
    std::cout << "赋值 Rectangle" << std::endl;
    if (this == &other)
    {
        return *this;
    }
    delete[] subName;
    subName = new char[strlen(other.subName) + 1];
    strcpy(subName, other.subName);
    return *this;
}

main函数中调用:

Rectangle t1("矩形", "四边形");
Rectangle t2;
std::cout << "--------开始赋值t2----------" << std::endl;
t2 = t1;
std::cout << "--------结束赋值t2-------------" << std::endl;
std::cout << "t2 name:" << t2.getName() << " subName:" << t2.getSubName() << std::endl;

输出结果:

默认构造 Shape
默认构造 Rectangle
默认构造 Shape
默认构造 Rectangle
--------开始赋值t2----------
赋值 Rectangle
--------结束赋值t2-------------
t2 name:default name subName:矩形
析构 Rectangle
析构 Shape
析构 Rectangle
析构 Shape

可以看到根本就不进父类的赋值操作符重载函数。所以t2的继承自父类的数据没有被执行赋值操作符重载函数,导致结果与预期不符。那么修正的方式就是在子类的赋值操作符重载函数里面显式调用父类的方法:

Rectangle &operator=(const Rectangle &other)
{
    std::cout << "赋值 Rectangle" << std::endl;
    if (this == &other)
    {
        return *this;
    }

    //显示调用父类的赋值操作符重载函数
    Shape::operator=(other);

    delete[] subName;
    subName = new char[strlen(other.subName) + 1];
    strcpy(subName, other.subName);
    return *this;
}

对于移动赋值操作符重载函数,也是一个道理。

posted @ 2025-04-25 14:44  thammer  阅读(36)  评论(0)    收藏  举报