跳转到内容
新建笔记

C++ union 与 variant:活动成员、构造析构和配置建模

一个测量任务可能保存总体值配置 OverallConfig,也可能保存频谱配置 SpectrumConfig。问题首先是如何表达“当前是哪一种配置”,其次才是如何节省内存。

原笔记把含 QString、QVector 的配置直接放进 union,又尝试在外层补一个析构函数。这里要分别处理:默认特殊成员是否被删除、哪个成员的生命周期已开始,以及标签是否与实际对象一致。本文限定 C++17;Qt 业务示例使用 Qt 6 Core。

非平凡成员可以进入 union,但不能忽略生命周期

跳转到“非平凡成员可以进入 union,但不能忽略生命周期”

“非平凡”不是“代码复杂”的同义词,要说明具体是默认构造、复制、移动还是析构操作非平凡。普通 class 含 std::string 或 QString 成员,并不会因此自动丧失所有复制和析构能力。

union 的成员共享存储,同一时刻至多有一个活动成员。C++17 中,某成员的相应特殊操作非平凡时,union 的对应默认操作可能被删除;必须按具体操作的规则判断。以 std::string 为成员的 union 为例,不能直接依赖自动生成的默认构造和析构来管理它。C++17 union 规则

外层 MeasurementSetup、再外层 TaskTemplate 可能因包含不可析构的子对象而连带不可析构。只给最外层写一个析构函数,不会自动修复内部 union 已删除的析构函数。

原手工版本还存在更直接的错误:构造了 overall,却留下表示 spectrum 的标签,退出时对未活动的 spectrum 调用析构。这个操作是未定义行为;不能靠它观察到的某次运行结果推导正确性。

为互斥配置选择表示方式

跳转到“为互斥配置选择表示方式”
表示方式得到什么必须另外处理什么
std::variant<monostate, OverallConfig, SpectrumConfig>类型标签与活动值由一个对象管理,monostate 表示尚未配置合法业务组合、错误访问和可能的异常空值
两个 unique_ptr 加一个 bool自动释放各自拥有的对象,可以延后创建两个指针可能同时非空或都为空,bool 也可能不一致;需要集中维护约束
手写带标签 union精确控制共享存储和对象寿命构造、销毁、切换、异常、复制和移动的全部规则

两个智能指针的析构本来就会释放资源,无需在析构函数里根据 bool 再手动 reset()。额外声明析构函数还可能抑制隐式移动;含 unique_ptr 的类是否允许复制,也要明确设计。原 void* 版本还把不存在的命名类型写成析构函数,不能当作可用方案。特殊成员函数

variant 的替代值保存在自身存储内,不为替代值对象额外分配空间;这不代表它内部的 QString、容器永远不分配。复制能力取决于各替代类型。get_if<T> 不匹配时返回空指针;get<T> 不匹配会抛出 bad_variant_access。某些抛异常的类型切换还可能使 variant 进入 valueless_by_exception 状态,这与正常持有 monostate 不同。variant 的值和访问规则

原任务模板的完整字段不能在修复时丢掉

跳转到“原任务模板的完整字段不能在修复时丢掉”

同一问题的另一份原稿给出了更完整的 fileparser.h。这些业务字段与 union 的存储问题是两层事情:修复生命周期时,应保留所需数据模型,再决定哪些类型互斥。原声明的字段如下。

类型原字段及类型
FrequencyRangedouble min = 0、double max = 0
OverallTypeItemQString resourceId、QString type
FrequencySpectrumdouble maxFrequency = 0、QString maxType、double minFrequency = 0
AverageSettingsint number = 0、int overlapPercent = 0、double factor = 0、QString type
TimeWaveformConfigQString name、QString resourceId、QString quantity
OverallConfigQString name、QString quantity、int measurementTime = 0、QVector<OverallTypeItem> typeItems、FrequencyRange frequencyRange
SpectrumConfigQString name、QString resourceId、QString quantity、int lines = 0、QString windowFunction、TimeWaveformConfig timeWaveformConfig、AverageSettings averageSettings、FrequencySpectrum frequencySpectrum
MeasurementSetupQString kind,以及原来的 config:bool isOverall = false 和共享存储的 overall / spectrum
TaskTemplateQString version、QString taskId、QString name、QString source、MeasurementSetup measurementSetup

原类还声明了公开静态入口 parseFromFile(const QString&),以及私有静态辅助函数 parseMeasurementSetup、parseSpectrumConfig、parseOverallConfig。这些只是解析职责的接口声明,原文没有给出可运行的文件解析实现;不能凭后面的省略号认定所有字段已经读取、校验或保存。

原建议同时写了 MeasurementSetup() = default、~MeasurementSetup() = default,又把 union 改成同时拥有两个配置的普通 struct。若普通 struct 能正常生成特殊成员,原因在于每个子对象都有自己的存储并按正常规则构造、销毁;这改变了“互斥共享存储”的表示。= default 本身不能让一个因子对象限制而被删除的函数重新可用,也不会替 union 选择活动成员。

TaskTemplate taskTemplate; 仍然要求这个类型可构造、可析构。将对象改为局部变量或交给 unique_ptr 所有,同样需要正确管理被拥有对象的生命周期;它们都不能绕过被删除的析构函数。应先修正配置类型,再实现文件读取和解析。

下方完整程序保留两种互斥配置及取列用途,但刻意使用较小的教学字段集;它没有实现此表的全部业务字段。扩展真实工程时,可把表中 OverallConfig、SpectrumConfig 的完整值类型放入 variant,并按实际文件格式逐项解析、验证和提交。不要把两段示例的同名结构体直接放进同一个翻译单元。

完整业务例子:先解析候选配置,再替换旧值

跳转到“完整业务例子:先解析候选配置,再替换旧值”

下例保留 kind、总体值/频谱两种配置,以及按“名称、任务ID、来源、量值”取列值的用途。XML 只定义一个教学子集:无命名空间的 meas_setup 根节点有非空 kind,且恰好包含一个 overall 或 spectrum 子节点,子节点必须有非空 quant 属性。其他属性暂不校验;这不是厂商任务模板的完整规范。

解析器有 64 KiB 输入上限,拒绝 DTD、额外层级、非空文本、重复配置和 XML 错误。它始终检查读到结尾的状态,修复原循环在畸形输入下无法结束的问题。失败时原对象保持不变。

#include <QByteArray>
#include <QString>
#include <QVector>
#include <QXmlStreamReader>
#include <cassert>
#include <type_traits>
#include <utility>
#include <variant>
struct OverallConfig { QString quantity; double value = 0.0; };
struct SpectrumConfig { QString quantity; QVector<double> bins; };
using Configuration = std::variant<std::monostate, OverallConfig, SpectrumConfig>;
struct MeasurementSetup { QString kind; Configuration config; };
struct TaskTemplate {
QString name;
QString taskId;
QString source;
MeasurementSetup measurementSetup;
};
bool parseMeasurementSetup(const QByteArray& bytes, MeasurementSetup& output, QString& error) {
error.clear();
if (bytes.size() > 64 * 1024) { error = "input too large"; return false; }
MeasurementSetup candidate;
QXmlStreamReader xml(bytes);
int depth = 0;
int configurations = 0;
bool rootSeen = false;
auto fail = [&](const QString& message) { error = message; return false; };
while (!xml.atEnd()) {
xml.readNext();
if (xml.isDTD()) return fail("DTD is not allowed");
if (xml.isStartElement()) {
++depth;
if (!xml.namespaceUri().isEmpty()) return fail("unexpected namespace");
if (depth == 1 && !rootSeen && xml.name() == u"meas_setup") {
rootSeen = true;
candidate.kind = xml.attributes().value(u"kind").toString();
if (candidate.kind.isEmpty()) return fail("missing kind");
} else if (depth == 2 && rootSeen && ++configurations == 1) {
const QString quantity = xml.attributes().value(u"quant").toString();
if (quantity.isEmpty()) return fail("missing quant");
if (xml.name() == u"overall")
candidate.config = OverallConfig{quantity, 0.0};
else if (xml.name() == u"spectrum")
candidate.config = SpectrumConfig{quantity, {}};
else return fail("unknown configuration");
} else return fail("unexpected element or duplicate configuration");
} else if (xml.isEndElement()) {
--depth;
} else if (xml.isCharacters() && !xml.isWhitespace()) {
return fail("unexpected text");
}
}
if (xml.hasError()) return fail(xml.errorString());
if (!rootSeen || configurations != 1 || depth != 0) return fail("missing configuration");
static_assert(std::is_nothrow_move_assignable_v<MeasurementSetup>);
output = std::move(candidate);
return true;
}
QString getValueByColumn(const TaskTemplate& task, const QString& column) {
if (column == QStringLiteral("名称")) return task.name;
if (column == QStringLiteral("任务ID")) return task.taskId;
if (column == QStringLiteral("来源")) return task.source;
if (column != QStringLiteral("量值")) return {};
const auto& config = task.measurementSetup.config;
if (config.valueless_by_exception()) return QStringLiteral("配置异常");
return std::visit([](const auto& value) -> QString {
using T = std::decay_t<decltype(value)>;
if constexpr (std::is_same_v<T, std::monostate>) return {};
else return value.quantity;
}, config);
}
int main() {
TaskTemplate task{QStringLiteral("Balance"), QStringLiteral("task-001"),
QStringLiteral("factory"), {}};
assert(std::holds_alternative<std::monostate>(task.measurementSetup.config));
assert(getValueByColumn(task, QStringLiteral("量值")).isEmpty());
QString error;
assert(parseMeasurementSetup(
R"(<meas_setup kind="overall"><overall quant="acc"/></meas_setup>)",
task.measurementSetup, error));
assert(std::holds_alternative<OverallConfig>(task.measurementSetup.config));
assert(parseMeasurementSetup(
R"(<meas_setup kind="trending_spectrum_amplitude"><spectrum quant="acc"/></meas_setup>)",
task.measurementSetup, error));
auto* spectrum = std::get_if<SpectrumConfig>(&task.measurementSetup.config);
assert(spectrum != nullptr);
spectrum->bins = {1.0, 2.0};
assert(std::get_if<OverallConfig>(&task.measurementSetup.config) == nullptr);
assert(getValueByColumn(task, QStringLiteral("名称")) == "Balance");
assert(getValueByColumn(task, QStringLiteral("任务ID")) == "task-001");
assert(getValueByColumn(task, QStringLiteral("来源")) == "factory");
assert(getValueByColumn(task, QStringLiteral("量值")) == "acc");
assert(getValueByColumn(task, QStringLiteral("未知列")).isEmpty());
TaskTemplate copied = task;
std::get<SpectrumConfig>(copied.measurementSetup.config).bins.append(3.0);
assert(spectrum->bins.size() == 2);
assert(std::get<SpectrumConfig>(copied.measurementSetup.config).bins.size() == 3);
bool wrongAccessRejected = false;
try { (void)std::get<OverallConfig>(task.measurementSetup.config); }
catch (const std::bad_variant_access&) { wrongAccessRejected = true; }
assert(wrongAccessRejected);
const QVector<QByteArray> invalid{
"", "<meas_setup kind='x'>", "<meas_setup kind='x'/>",
"<meas_setup><overall quant='acc'/></meas_setup>",
"<meas_setup kind='x'><spectrum/></meas_setup>",
"<meas_setup kind='x'><unknown quant='acc'/></meas_setup>",
"<meas_setup kind='x'><overall quant='acc'/><spectrum quant='acc'/></meas_setup>",
"<meas_setup xmlns='urn:wrong' kind='x'><overall quant='acc'/></meas_setup>",
"<meas_setup kind='x'><overall quant='acc'>bad</overall></meas_setup>",
"<!DOCTYPE meas_setup><meas_setup kind='x'><overall quant='acc'/></meas_setup>",
QByteArray(64 * 1024 + 1, 'x')
};
for (const auto& bytes : invalid) {
assert(!parseMeasurementSetup(bytes, task.measurementSetup, error));
assert(!error.isEmpty());
assert(std::get<SpectrumConfig>(task.measurementSetup.config).bins.size() == 2);
}
}

本例从配置的活动类型判断 overall/spectrum,不再额外保存一个容易失配的 bool。kind 仍是输入中的测量描述;它与配置种类的业务对应关系应由真实文件规范另外规定。完整任务模板的更多字段及命名空间处理见 Qt XML 任务模板。

把上限放在这里限制的是交给解析器的字节数组。读取磁盘文件时还应采用有上限的读取并处理 I/O 错误,不能先无限制读取再声称内存已被保护。QXmlStreamReader 的状态和错误接口见 Qt 官方文档。

手写 union 时必须把标签与对象一起管理

跳转到“手写 union 时必须把标签与对象一起管理”

下例是独立的标准 C++17 教学程序,用字符串资源说明底层规则。Storage 有自己的构造与析构;外层负责活动成员。为避免默认复制存储字节造成错误,示例显式禁止复制和移动。

切换到字符串前,先销毁旧活动值;新构造完成后才设置 Resource 标签。注入的构造异常会让对象保持 Empty 状态,可继续使用和销毁,但不承诺保留原值。它与前面的“先解析候选对象再提交”采用不同的异常保证。

#include <cassert>
#include <memory>
#include <new>
#include <stdexcept>
#include <string>
#include <type_traits>
#include <utility>
struct Resource {
inline static int live = 0;
std::string text;
Resource(std::string input, bool fail) : text(std::move(input)) {
if (fail) throw std::runtime_error("requested construction failure");
++live;
}
~Resource() { --live; }
};
class TaggedValue {
union Storage {
char empty;
int number;
Resource resource;
Storage() noexcept : empty(0) {}
~Storage() {} // 活动 Resource 由 TaggedValue 析构
} storage_;
enum class Kind { Empty, Number, Resource };
Kind kind_ = Kind::Empty;
public:
TaggedValue() = default;
~TaggedValue() { reset(); }
TaggedValue(const TaggedValue&) = delete;
TaggedValue& operator=(const TaggedValue&) = delete;
TaggedValue(TaggedValue&&) = delete;
TaggedValue& operator=(TaggedValue&&) = delete;
void reset() noexcept {
if (kind_ == Kind::Resource) storage_.resource.~Resource();
kind_ = Kind::Empty;
storage_.empty = 0;
}
void setNumber(int value) {
reset();
new (static_cast<void*>(std::addressof(storage_.number))) int(value);
kind_ = Kind::Number;
}
void setResource(std::string text, bool fail = false) {
reset();
new (static_cast<void*>(std::addressof(storage_.resource)))
Resource(std::move(text), fail);
kind_ = Kind::Resource;
}
bool empty() const noexcept { return kind_ == Kind::Empty; }
int number() const {
if (kind_ != Kind::Number) throw std::logic_error("not a number");
return storage_.number;
}
const std::string& text() const {
if (kind_ != Kind::Resource) throw std::logic_error("not a resource");
return storage_.resource.text;
}
};
int main() {
static_assert(!std::is_copy_constructible_v<TaggedValue>);
static_assert(!std::is_move_constructible_v<TaggedValue>);
assert(Resource::live == 0);
{
TaggedValue value;
assert(value.empty());
value.setResource("spectrum");
assert(Resource::live == 1 && value.text() == "spectrum");
value.setNumber(42);
assert(Resource::live == 0 && value.number() == 42);
value.setResource("overall");
assert(Resource::live == 1);
bool failed = false;
try { value.setResource("replacement", true); }
catch (const std::runtime_error&) { failed = true; }
assert(failed && value.empty() && Resource::live == 0);
value.reset();
value.setResource("ready again");
assert(Resource::live == 1 && value.text() == "ready again");
}
assert(Resource::live == 0);
}

空的 Storage 析构函数只让存储层可析构,并没有自动释放字符串;真正的销毁来自外层按标签调用的析构。定位构造也只在已经具备足够大小和对齐的存储中开始对象寿命。若以后允许复制或移动,必须逐种活动类型构造目标并维护异常路径,不能简单复制整个 union 的字节。对象生命周期规则

对于这里的测量配置,variant 能减少需要手工维持的状态。低层 union 程序用于理解原报错和修复边界,不必把它扩展成业务层自己的 variant 库。