Read Buf

Read Buf

在 C++ 中如果不想要默认构造函数怎么办?

language

默认构造函数真的必要吗?有什么意义?如果没有默认构造函数会怎样?本文将探讨这些问题。

默认构造函数是一种不带参数的构造函数,它会给所有成员变量赋默认值。如果没有定义任何构造函数,编译器会自动生成一个。

是否需要默认构造函数?

这取决于具体情况。首先从设计角度来看,成员变量默认初始化是否合理?

比如,对于一个计时器设备,所有计数器初始化为零是合理的。

计时器是一种记录重型车辆驾驶员的驾驶时间、休息时间及其他工作的设备。

然而,对于一个人或者任务标识符(这也是我写这篇文章的灵感来源),默认构造函数就不合理了。一个名字为空的人的对象或者 ID 为空的任务标识符没有意义。

从设计角度,这无疑是如此。那么从技术角度来看呢?如果没有默认构造函数,会发生什么?

使用一些标准库类型会遇到困难。

#include <map>
#include <string>
#include <vector>

class TaskID {
 public:
    TaskID(std::string uuid): m_uuid(uuid){};

    auto operator<=>(const TaskID&) const = default;

    void serialize(std::string &out_buffer) const {
        out_buffer.resize(sizeof(TaskID));
        memcpy(out_buffer.data(), reinterpret_cast<const char *>(this), sizeof(TaskID));
    }
 private:
    std::string m_uuid;
};

void foo(const TaskID& taskID) {
   // ...
   taskID.serialize();
}


int main() {
    std::vector<TaskID> tasks;
    
    // 编译错误,resize() 需要默认构造函数
    // tasks.resize(10);

    // 没有默认构造函数,不能执行
    // std::vector<TaskID> moreTasks(10);

    std::map<TaskID, std::string> tasksMap{ {TaskID{"ab12"}, "dummy"} };
    tasksMap[TaskID{"ab13"}] = "other dummy";


    std::map<int, TaskID> tasksMap2;
    // 无法使用 operator[],值类型需要默认构造函数 
    // tasksMap2[4] = TaskID{"ab13"};

    foo(tasksMap.at(42));
}

这只是两个例子。没有默认构造函数,使用 std::vectorstd::map 可能会遇到麻烦。但这些限制并非无法克服。

还有更难接受的限制。

假设你有一个类 Widget,它包含一个 TaskID 成员变量。虽然这是可能的,但该类不能有自动生成的默认构造函数,因为这会要求 TaskID 具有默认构造函数。

假设在构造时,我们无法提供有意义的 TaskID 值,只能以后确定。

#include <map>
#include <string>
#include <vector>

class TaskID {
 public:
    TaskID(std::string uuid): m_uuid(uuid){};

    auto operator<=>(const TaskID&) const = default;
 private:
    std::string m_uuid;
};


class Widget {
public:
    // ...
    TaskID getTaskID() const { return taskId; } 

private:
    TaskID taskId;
};

void foo(const TaskID& taskID) {
   // ...
   taskID.serialize();
}

int main() {
   // 错误:调用了隐式删除的 Widget 默认构造函数
    Widget widget;

    foo(widget.getTaskID());
}

可以为 Widget 提供一个默认构造函数,但如何实例化一个默认的 TaskID 或任何没有默认值的类呢?

仍然使用默认构造函数

可以给成员变量赋一些虚拟值。对于整数,通常用 -1 表示无效状态;对于字符串,可以用一个空值。事实上,大多数人不会深入思考这些问题,他们只是让对象使用成员的默认值。

如果看到代码无法编译,因为缺少默认构造函数,他们会直接添加一个。

如果更谨慎一些,他们可能会定义一个 isValid() 函数来判断对象是否有效。

class TaskID {
 public:
    TaskID() = default;
    TaskID(std::string uuid): m_uuid(uuid){};

    auto operator<=>(const TaskID&) const = default;

    bool isValid() const {
      return !m_uuid.empty();
    }
 private:
    std::string m_uuid;
};

void foo(const TaskID& taskID) {
   if (!taskID.isValid()) {
      return;
   }
   // ...
   taskID.serialize();
}

但如果不想创建无意义默认值的对象,我们有其他选择吗?

std::optional 包装对象

为了避免使用默认构造函数,同时还能将对象作为类成员或与标准容器一起使用,可以用 std::optional 包装 TaskID

#include <map>
#include <optional>
#include <string>
#include <vector>

class TaskID {
 public:
    TaskID(std::string uuid): m_uuid(uuid){};

    auto operator<=>(const TaskID&) const = default;
 private:
    std::string m_uuid;
};

void foo(std::optional<TaskID> taskID) {
   if (!taskID) {
      return;
   }
   // ...
   taskID.serialize();
}

int main() {
    std::vector<std::optional<TaskID>> tasks;
 
    tasks.resize(10);
    std::vector<std::optional<TaskID>> moreTasks(10);

    std::map<TaskID, std::string> tasksMap{ {TaskID{"ab12"}, "dummy"} };
    tasksMap[TaskID{"ab13"}] = "other dummy";


    std::map<int, std::optional<TaskID>> tasksMap2;
    tasksMap2[4] = TaskID{"ab13"};

    foo(tasksMap2[42]);
}

这意味着什么呢?

实际上,我们必须验证 TaskID 是否存在以避免错误访问。这增加了一层验证,可能会麻烦。

从语义上看,这意味着 TaskID 要么存在要么不存在。虽然这并不是我们完全想表达的,通常我们想说的是 TaskID 还不可用或者已经存在。而 std::optional 的表达能力到此为止。

使用 std::variant

std::variant<Ts...> 类似于增强版的 std::optional<T>。它可以包含多种类型。因此,我们可以用 std::variant 表示 TaskIDInvalidTaskIDUninitialziedTaskID 等。

class TaskID { /* 和之前一样 */ };
struct InvalidTaskID {};
struct UninitialziedTaskID {};

std::variant<UninitialziedTaskID, InvalidTaskID, TaskID> taskID;

我将 UninitialziedTaskID 放在第一个位置,因为默认情况下变量会初始化为它。

相比 std::optional,它的使用并不复杂,但你可能需要在代码中使用大量的 std::holds_alternative<T>std::get<T>,这会影响可读性。

void foo(std::variant<UninitialziedTaskID, InvalidTaskID, TaskID> taskID) {
   if (!std::holds_alternative<TaskID>(taskID)) {
      return;
   }
   // ...
   taskID.serialize();
}

void bar(std::variant<UninitialziedTaskID, InvalidTaskID, TaskID> taskID) {
   try {
      std::get<TaskID>(taskID).serialize();
   } catch (std::bad_variant_access const& ex) {
        std::cout << ex.what() << ": taskID 中不包含 TaskID\n";
   }
}

使用 std::variant 结合 enum

还有一个有趣的选择。我们可以用 std::variant 结合枚举,将 TaskID 作为第二项,枚举作为第一项以表示无效状态。

#include <string>
#include <variant>
#include <vector>

enum class InvalidTaskIDStates {
    InvalidTaskID,
    UninitialziedTaskID,
};

class TaskID {
 public:
    TaskID(std::string uuid): m_uuid(uuid) {};
 private:
    std::string m_uuid;
};

void foo(std::variant<InvalidTaskIDStates, TaskID> taskID) {
   if (std::holds_alternative<InvalidTaskIDStates>(taskID)) {
      return;
   }
   // ...
   taskID.serialize();
}

void bar(std::variant<InvalidTaskIDStates, TaskID> taskID) {
   if (std::holds_alternative<InvalidTaskIDStates>(taskID)) {
      switch (std::get<InvalidTaskIDStates>(taskID)) {
         case InvalidTaskIDStates::InvalidTaskID:
            std::cout << "InvalidTaskID\n";
            return;
         case InvalidTaskIDStates::UninitialziedTaskID:
            std::cout << "UninitialziedTaskID\n";
            return;
      }
   }
   // ...
   taskID.serialize();
}

int main() {
    std::variant<InvalidTaskIDStates, TaskID> myvar;
    std::vector<std::variant<InvalidTaskIDStates, TaskID>> tasks;
    tasks.resize(10);
    tasks.push_back(TaskID{"ab12"});
    foo(tasks.back());
    bar(tasks.front());
}

优点是,如果我们对无效状态感兴趣,只需检查一个变体。但如果我们对具体的无效状态感兴趣,需要结合 std::variant 的 API 和枚举语法。

总 结

今天,我们探讨了不希望类型有默认构造函数的几种选择。即使在这种情况下,具有默认构造函数可能也有意义,能将对象初始化为无效状态。尽管这不是最佳实践。

没有默认构造函数的类型有时难以与容器结合使用。当需要组合其他具有默认构造函数的类型,但某个成员没有默认构造函数时,我们可以将这些类型包装到 std::optionalstd::variant 中。

当然,我们也可以使用智能指针,但这不是我们讨论的范围,因为动态内存分配并不是解决原始问题的方法。