一个简单的类来封装 VARIANT






4.39/5 (28投票s)
在 C++ 代码中使用 Variants。
引言
最近我一直在处理 MSHTML 控件,它的大部分参数都是 `VARIANT` 类型。我都能想象你已经皱起了眉头。一个体面的 C++ 程序员怎么会去弄那些 VB/脚本语言的数据类型呢?嗯,这是两害相权取其轻。要么我学习 `VARIANT`,要么我写我自己的 HTML 解析器和编辑器。你觉得哪个更容易?我知道你会同意我的 :)
老实说,我认为 `VARIANT` 的概念其实很酷。将你的数据封装在一个漂亮的小包里,带上一两个类型描述符,将它跨函数调用边界传递,让另一端去处理。如果处理得当,它可以解决许多原本棘手的问题。我只是希望它们更容易使用!
好了,道歉的话就说到这里。让我们来看看 `VARIANT` 是什么。
为什么需要 VARIANT?
与 C++ 这种强类型语言相比,Visual Basic 和许多脚本语言都是弱类型的。这意味着在强类型语言中,你必须将函数编写时期望的确切类型的参数传递给它。如果函数期望一个指向 `string` 的指针,你就不能传递一个整数。尝试这样做会得到一个编译时错误。
弱类型语言允许你传递不匹配预期类型的参数。那么问题就来了——如果你可以向函数传递错误的参数类型,语言会如何响应?大多数弱类型语言会“强制转换”(coerce)传入的值为预期的类型。这是什么意思?这意味着语言运行时会尝试将传入的数据转换为正确的数据类型。例如,如果你将一个整数传递给一个期望 `string` 的函数,最自然的“强制转换”是将整数转换为 `string` 表示形式。如果将日期传递给期望 `string` 的地方,自然的“强制转换”是将其转换为 `string` 表示形式。
作为 C++ 程序员,我们已经在小范围内习惯了强制转换——我们习惯了编译器可以将 `short` 提升为 `int` 等等。弱类型语言只是在这方面做得更进一步。
那么,这与 `VARIANT` 有什么关系呢?
想象一下你在设计自己的编程语言。你知道你想支持哪些数据类型。你知道你想要哪些内置运算符。你可以设计你的编译器来跟踪程序中所有数据的类型,这样当程序员将错误的数据类型传递给函数时,你的编译器就会知道,并可以插入必要的代码来转换数据。
现在想象一下,你不仅需要支持自己的语言,还需要支持另一种语言(例如 C++)。你完全控制自己的语言,但对第二种语言完全没有控制。然而,你希望能够与该语言进行互操作。既然是你想要与你无法改变的东西进行互操作,那么你就必须适应“你无法改变的东西”。因此,你设计你的数据类型,使其包含足够的信息,除了它封装的数据之外,还能让其他人解读其内容。
引出 VARIANT
`VARIANT` 是一种解决这个问题的、并非过于复杂的方式。简化来说,`VARIANT` 看起来是这样的
struct tagVARIANT
{
union
{
VARTYPE vt;
WORD wReserved1;
WORD wReserved2;
WORD wReserved3;
union
{
LONG lVal;
BYTE bVal;
SHORT iVal;
FLOAT fltVal;
DOUBLE dblVal;
VARIANT_BOOL boolVal;
DATE date;
BSTR bstrVal;
SAFEARRAY *parray;
VARIANT *pvarVal;
};
};
};
这是 `VARIANT` 定义的一个**非常**简化的版本,你可以在你最近的 `oaidl.h` 文件中找到完整的定义。我不知道 `wReserved` 值是什么意思,我也不关心。
我们感兴趣的是 `vt` 值和联合体(union)。`vt` 是 `valuetype`(值类型),联合体(union)是值。你会看到联合体包含了 `LONG`、`BYTE`、`SHORT`、`FLOAT` 等(有很多很多)。`vt` 告诉我们如何解释这个值,使用成员名称。在 C++ 中,你可以这样做:
void SomeFunc(VARIANT& v)
{
USES_CONVERSION;
if (v.vt == VT_I4)
printf(_T("variant value is %d\n"), v.lVal);
else if (v.vt == VT_BSTR)
printf(_T("variant value is %s\n"), W2A(v.bstrVal));
}
这会检查 `VARIANT` 的 `vt` 成员。如果它是 `VT_I4`,那么我们想要的数据就包含在联合体的 `lVal` 成员中。由于 `lVal` 成员是一个 `LONG`,我们可以在 `printf` 调用中使用 `%d` 作为格式说明符。如果它是 `VT_BSTR`,那么数据就是包含在联合体的 `bstrVal` 成员中的 `BSTR`。
请注意 `VARIANT` 如何使用 `BSTR` 数据类型来传递 `string` 数据。这样做是为了让 `VARIANT` 能够在进程边界上传递,而无需进行封送(marshalling)开销。还有许多其他数据类型(本文未讨论)需要封送才能跨越进程边界,但 `string` 的传递非常普遍,使用 `BSTR` 来规避封送是一个很好的优化。
将 VARIANT 封装在一个简单的类中
基于我们之前看到的代码片段,将 `VARIANT` 的复杂细节隐藏在一个类中是很有意义的。我们可以这样做:
class CVariant : public VARIANT
{
public:
CVariant();
CVariant(int iValue);
CVariant(LPCTSTR szValue);
LPCTSTR ToString() const;
int ToInt() const;
};
例如,`CVariant(int iValue)` 重载构造函数的实现可能看起来像这样:
CVariant::CVariant(int iValue)
{
vt = VT_I4;
lVal = iValue;
}
而 `ToString()` 函数的实现可能看起来像这样:
LPCTSTR CVariant::ToString() const
{
USES_CONVERSION;
if (VT_BSTR == vt)
return W2A(bstrVal);
// It's not a string so return an empty string
return _T("");
}
这通过隐藏处理 `VARIANT` 类型或转换其内容等复杂细节来稍微简化了代码,但这还不足以创建一个新类,更不用说写一篇文章来介绍了。
将 VARIANT 封装在一个更复杂的类中
上面显示的简单类可能足以满足大多数偶然的 `VARIANT` 使用。对于我在引言中提到的 MSHTML 控件来说,它绝对是足够的。但对于其他环境来说,它可能不够。例如,几年前,我编写了大量使用 Microsoft Chat Protocol 控件的软件,该控件似乎是由一个成员们只懂 VB 的委员会设计的。主机和控件之间传递的几乎所有数据都通过 `VARIANT`,其中一些 `VARIANT` 是数组。`VARIANT` 使用 `SAFEARRAY` 结构来表示数组。
`SAFEARRAY` 的定义看起来是这样的(这是 Win32 定义——对于 WinCE 来说稍微有点不同)。
typedef struct tagSAFEARRAY
{
USHORT cDims; // How many dimensions in this array
USHORT fFeatures; // Allocation control flags
ULONG cbElements; // The size of each array element
ULONG cLocks; // Array lock count.
PVOID pvData; // Points at the data in the array
SAFEARRAYBOUND rgsabound[1];
} SAFEARRAY;
你一定会喜欢 `SAFEARRAYBOUND` 成员的用途。它是一个结构,用于指定此维度的元素数量和下界。这允许 `SAFEARRAY` 的某个维度的索引可以从任意数字开始,而不是我们 C/C++ 程序员所熟悉的 0。有一个这些结构的数组,每个 `cDim` 对应一个。
因此,在 C++ 中访问 `VARIANT` 数组涉及解释 `VARIANT` 的内容为指向 `SAFEARRAY` 的指针,将第一个数组索引与 `cDims` 进行验证,以确保它在范围内,然后根据 `cbElements` 的大小索引到 `pvData`,并考虑 `rgsabound` 数组中该索引项的内容。呼,真是说来话长!
突然之间,似乎一个封装这些东西的类可能会很有用。
类本身
注意事项
这里呈现的类并非涵盖了所有可能性;远非如此。它涵盖了我使用 Microsoft Chat Protocol 控件和 MSHTML 控件时遇到的情况。我怀疑 Visual Basic 中处理 `VARIANT` 类型所有可能性的代码比这里呈现的类要复杂得多。
这个类可以处理带有符号整数数据类型或 `string` 的简单 `VARIANT`。它还可以处理一维数组,其中数组的每个元素都是一个 `VARIANT`,该 `VARIANT` 可以是类处理的任何简单类型。如果你想要更多,可以按照代码查看如何处理额外的类型。我不需要超出支持范围的类型,所以我没有为这些类型编写支持。
好了,免责声明就到这里。这是类的头文件:
class CVariant : public VARIANT
{
public:
CVariant();
CVariant(bool bValue);
CVariant(int nValue);
CVariant(LPCTSTR szValue);
CVariant(VARIANT *pV);
CVariant(int lBound, int iElementCount);
~CVariant(void);
// Attributes
BOOL IsArray(int iElement = 0);
BOOL IsString(int iElement = 0);
BOOL IsInt(int iElement = 0);
BOOL IsBool(int iElement = 0);
// Conversions
VARIANT *operator&() { return this; }
// Get operations
VARIANT *ElementAt(int iElement = 0);
CString ToString(int iElement = 0);
int ToInt(int iElement = 0);
BOOL ToBool(int iElement = 0);
// Set operations
void Set(LPCTSTR szString, int iElement = 0);
void Set(int iValue, int iElement = 0);
void Set(bool bValue, int iElement = 0);
};
你已经看到了简单的构造函数。还有两个其他构造函数。第一个构造函数允许你定义一个数组。它接受索引的下界和一个元素的数量。代码如下:
CVariant::CVariant(int lBound, int iElementCount)
{
// Set the type to an array of variants...
vt = VT_ARRAY | VT_VARIANT;
parray = new SAFEARRAY;
// We only support 1 dimensional arrays..
parray->cDims = 1;
parray->fFeatures = FADF_VARIANT | FADF_HAVEVARTYPE | FADF_FIXEDSIZE | FADF_STATIC;
parray->cbElements = sizeof(VARIANT);
parray->cLocks = 0;
// Allocate the array of variants we point to...
parray->pvData = new VARIANT[iElementCount];
memset(parray->pvData, 0, sizeof(VARIANT) * iElementCount);
parray->rgsabound[0].lLbound = lBound;
parray->rgsabound[0].cElements = iElementCount;
}
根据我之前对 `SAFEARRAY` 结构的描述,这应该很清楚了。我们只支持一维数组,所以我们将新创建的 `SAFEARRAY` 实例的各种成员设置为反映这一点。新的 `SAFEARRAY` 的 `rgsabound[0]` 结构用我们的下界和计数变量设置。重要的是要记住,我们正在创建的 `VARIANT` 可能用于与用其他语言创建的模块进行互操作,而我们不能假设索引从 `0` 开始。你的索引从哪里开始取决于你正在与之互操作的内容。
`fFeatures` 成员需要一些解释。我使用的标志值指定数组包含固定大小的 `VARIANT`,并且是静态的(不在栈上创建)。我指定它是静态的,因为如果我需要分配内存,我将从堆上分配。
另一个构造函数允许你获取一个现有的 `VARIANT`(可能是传递给某个你正在托管的外部对象的事件处理程序)并将其附加到一个 `CVariant`。代码如下:
CVariant::CVariant(VARIANT *pV)
{
// Validate the input (and make sure it's writeable)
ASSERT(pV);
ASSERT(AfxIsValidAddress(pV, sizeof(VARIANT), TRUE));
vt = VT_VARIANT;
pvarVal = pV;
}
如果是调试版本,我们会做一些断言来确保它是一个指向有效内存块的指针,至少足够大,能够容纳一个 `VARIANT`。我们无法进行更多的运行时验证。一旦我们确定它**可能**是一个 `VARIANT`,我们就将指针赋值给 `pvarVal` 成员,并将类型设置为 `VT_VARIANT`。完成之后,我们就可以像自己创建它一样使用 `VARIANT` 的任何其他成员函数了。
警告 警告 警告
听好了。**绝不要**使用 `CVariant::CVariant(VARIANT *pV)` 构造函数来尝试在函数边界之间保留 `VARIANT`。使用此构造函数的唯一原因是将类包装器放在你从别处获取的 `VARIANT` 上。我不想说你获得这种 `VARIANT` 的唯一方式是来自事件,但我会说它几乎是 100% 的情况。这就是为什么没有 `Attach` 函数的原因。`Attach` 模式会诱使你尝试在函数边界之间保留某些东西。对于对象来说,它工作得很好,它们将长期存在,例如窗口句柄,但对于像 `VARIANT` 这样的东西来说,它就行不通,`VARIANT` 是即时创建的,用于与某个其他模块(如你的)通信。
请注意,没有尝试实现复制构造函数。生命太短暂,无法尝试编写这样的野兽。想想看。你的代码将不得不处理每一种可能的变体,并对数组中的数组中的数组进行深度复制。
VARIANT 属性
一旦我们通过任何方法创建了我们的 `CVariant`,我们就可以使用它。你不会用 `VARIANT` 来在程序的同一个函数之间进行通信。你可能也不想在 DLL 边界上传递它。开销太大,使 `VARIANT` 变得不那么吸引人。因此,几乎可以肯定,你正在与一些你自己没有写过的东西进行通信。因此,你可以调用几个函数来检查从你没写过的东西传递过来的东西的**数据类型**。
// Attributes
BOOL IsArray(int iElement = 0);
BOOL IsString(int iElement = 0);
BOOL IsInt(int iElement = 0);
BOOL IsBool(int iElement = 0);
这些 `IsAsomething()` 函数镜像了类支持的数据类型。如果你不确定特定 `VARIANT` 的类型,请使用这些函数来确定你即将执行的操作是否有成功的可能性。
为什么我不鼓励通过显式的成员函数访问 `vt` 成员?很高兴你问了。访问该成员会返回确切的类型。为什么这不好?因为它迫使你考虑所有无数的可能性。它可以是 `VT_USERDEFINED`、`VT_BLOB_OBJECT` 或 `VT_DISPATCH`。由于该类不处理这些类型,因此你无法从该信息中获得任何有用的东西。在我看来,更好地询问类:“你是一个 `string` 吗?”或者“你是一个整数吗?”如果答案是肯定的,那么你就可以继续执行有意义的操作。如果不是,你就进行适当的错误处理。
当然,没有什么可以阻止你显式访问 `vt` 成员,但如果你这样做了,你就要自行负责了。
VARIANT 访问
一旦确定了数据类型,就调用相应的访问器。访问器用于简单的 `VARIANT` 访问和数组访问,并接受一个默认值为零的参数。访问器会自动判断你是否有数组,并根据 `VARIANT` 的确切内容执行正确的操作。
数组的索引基数是什么?
由于这些是 `VARIANT` 和 `SAFEARRAY` 操作的 C++ 包装器,因此它们将数组视为 `OPTION BASE 0`。在内部,它们不一定需要(例如,它们可能来自 VB,其中设置了 `OPTION BASE 1`,但内部函数会纠正 `OPTION BASE`)。
访问器使用 `ElementAt()` 辅助函数来访问请求的数据,然后根据数据类型应用相应的数据转换。`ElementAt()` 函数看起来是这样的:
VARIANT *CVariant::ElementAt(int iElement)
{
if (vt == VT_VARIANT)
// It's a pointer to an external VARIANT
// so return that variant
return pvarVal;
if (!(vt & VT_ARRAY))
// It's not an array so return ourselves
return this;
// Calculate our element offset
int offset = iElement - pvarVal->parray->rgsabound[0].lLbound;
// Offset must be zero or greater and less than the bounds
if (offset >= 0 && offset <= int(pvarVal->parray->rgsabound[0].cElements))
return &((VARIANT *) pvarVal->parray->pvData)[offset];
else
return (VARIANT *) NULL;
}
你可以看到我之前说过的。如果 `VARIANT` 封装了一个从别处获得的 `VARIANT`,我们就返回那个 `VARIANT`。如果 `VARIANT` 不是数组,我们就返回一个指向自身的指针(记住该类派生自 `VARIANT` 结构,并且没有 `vtable`,所以 `this` 等同于指向基 `VARIANT` 结构的指针)。
否则,我们就有一个数组,所以我们计算 `SAFEARRAY` 的偏移量,考虑到存储在 `rgsabound` 结构中的下界。然后我们检查偏移量是否大于或等于 0 并且小于数组中的元素数量,如果符合条件,我们就返回指向 `SAFEARRAY` 元素的指针。如果你指定了一个无效的索引,你将收到一个 `NULL` 指针。
实际的访问器看起来是这样的:
CString CVariant::ToString(int iElement)
{
USES_CONVERSION;
// Get the VARIANT at the iElement offset
VARIANT *v = ElementAt(iElement);
// Must be a valid pointer and must be valid readable memory
if (v != (VARIANT *) NULL && AfxIsValidAddress(v, sizeof(VARIANT), FALSE) && v->vt == VT_BSTR)
return W2A(v->bstrVal);
return _T("");
}
非常简单。其他访问器的工作方式也大致相同。
请注意,`bool` 重载使用了小写的 `bool` 数据类型,而不是 `typedef` 的 `BOOL`。这是为了区分 `int` 和 `bool` 重载。我们需要不同的重载,以便能够创建一个类型为 `VT_BOOL` 的 `VARIANT`。
类型转换
上面呈现的类不执行任何“类型强制转换”(下载中的类实现也不执行)。这是故意的。虽然我接受“强制转换”的概念,但我不认为它适合 C++ 环境。我宁愿在运行时知道发生了错误,而不是被“有用的”类设计所掩盖。该类通过在 `VARIANT` 不是预期的数字数据时返回零,或者在不是 `string` `VARIANT` 时返回空 `string` 来确保安全,但它不执行类型强制转换。但是,如果你想实现类型强制转换,你可以这样做:
LPCTSTR CVariant::ToString() const
{
USES_CONVERSION;
CString csTemp;
switch (vt)
{
case VT_BSTR:
return W2A(bstrVal);
// It's not a string, maybe a number?
case VT_I4;
csTemp.Format(_T("%d"), lVal);
break;
case VT_I2:
csTemp.Format(_T("%d"), iVal);
break;
// and so forth...
}
return csTemp;
}
这段小片段在 `VARIANT` 确实包含 `string` 时返回转换后的 `string`。否则,它会尝试将数字数据转换为 `string` 表示形式并返回。最后,如果 `VARIANT` 类型不在 `switch` 语句的处理范围内,它会返回一个空 `string`。
历史
- 2004 年 3 月 19 日 - 初始版本
- 2004 年 3 月 20 日 - 添加了 `bool` 重载
- 2004 年 3 月 28 日 - 修复了 `ElementAt()` 函数中的一个错误