browser icon
You are using an insecure version of your web browser. Please update your browser!
Using an outdated browser makes your computer unsafe. For a safer, faster, more enjoyable user experience, please update your browser today or try a newer browser.

键歇(5)

Posted by on 2015 年 03 月 06 日

你可以任意转载本文,但请在转载后的文章中注明作者和原始链接。
媒体约稿请联系 titilima_AT_163.com(把“_AT_”换成“@”)。

在我看来,PASCAL 的语法着实是严谨死板,就像个不近人情、认死理儿的私塾老先生;但是,大概是在十来年前(2002、2003 的样子),我还真就喜欢过那么一阵子的 Delphi。

如果你猜我是因为 RAD 而喜欢 Delphi 的,那么你错了。其实,我的着眼点很简单——在 Object PASCAL 中,“字符串”这种东西被设定为一个基本的数据类型,完全不需要像 C 那样来依靠字符数组的形式还得一个字符一个字符地精打细算着用。换句话说,那时的我,其实是在逃避 C/C++ 之中最难掌握的知识点——指针、动态内存的分配与调度。

直到今天,我仍然很清楚地记得,那时的我踌躇满志地要写一个读写注册表的工具。但是,Win32 API 的 RegQueryValueEx 却让我望而止步了。究其原因,要查询的键值数据大小是不可预测的,因此使用这货必须遵循类似这样的步骤:先进行一次探测性的调用,在这次调用中使用 lpcbData 参数来接收数据的大小,然后再分配适当大小的内存并进行真实的调用来获取数据。这些步骤的大致代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
HKEY hKey;
 
// Open key ...
 
DWORD dwSize;
RegQueryValueEx(hKey, _T("SomeKey"), nullptr, nullptr, nullptr, &dwSize); // ERROR_MORE_DATA
 
PBYTE data = new BYTE[dwSize];
RegQueryValueEx(hKey, _T("SomeKey"), nullptr, nullptr, data, &dwSize);
 
// Use data ...
 
delete[] data;
 
// Close key ...

与之相比,VCL 的 TRegistry 类则显然是贴心多了,一个封装好 ReadString 函数就能搞定:

function ReadString(const Name: string): string;

当然,所谓封装,只不过是把代码根据需要藏起来罢了;至于实现封装用的是 Object PASCAL 还是 C++,那就没有太大的区别了,反正该有的步骤一样都不能缺。最终,随着对内存和指针驾驭能力的提高,我还是回到了 C/C++ 的阵营里,并为自己封装出了适用于 std::basic_string 的 RegKey 类。

故实讲完,那么引出本回《键歇》的话题——有关获取不确定大小的数据的那些事儿。

就像查询注册表键值数据一样,我们在很多场景下获取数据的时候都会遇到这样的问题:取数据之前并不知道目标数据有多大,需要先靠某种方式得知数据大小并为之分配空间,然后再进行获取。Win32 之中,这样的例子比比皆是,除了上述的 RegQueryValueEx,还有 WideCharToMultiByte、MultiByteToWideChar 等等。——当然,最为典型的例子则莫过于 GetWindowTextLength 和 GetWindowText 这一对儿拍档了。考虑如下代码(ATL 中 CWindow::GetWindowText 的实现):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class CWindow
{
public:
    // ...
    int GetWindowText(_Inout_ CSimpleString& strText) const // 1
    {
        int nLength;
        LPTSTR pszText;
 
        nLength = GetWindowTextLength(); // 2
        pszText = strText.GetBuffer(nLength + 1); // 3
        nLength = GetWindowText(pszText, nLength + 1); // 4
        strText.ReleaseBuffer(nLength);
 
        return nLength; // 5
    }
};

如你所见,明晰的函数命名已经甚为赅备地描述了整个过程中所有步骤;但是,我还是决定用中文来总结一下:

  1. 希望获取数据的一方(调用方)向持有数据的一方(被调用方)发起一个调用。
  2. 某方查询目标数据的大小。
  3. 某方根据目标数据的大小来分配相应的内存。
  4. 被调用方向目标内存复制数据。
  5. 执行的领空(context)回到调用方,整个的调用过程结束。

细心的你一定发现了,第 2~3 步我使用了“某”这个不确定的代词——是的,因为对于这两步而言,哪一方调用都是可以的。那么,问题来了:这两个“某处”究竟由哪一方调用才是更优的策略呢?

还是以 CWindow::GetWindowText 为例吧。通常,我们是这样写码的:

CWindow wnd;
// ...
CString strText;
wnd.GetWindowText(strText);

如果严格按照字面概念来对号入座的话,我们自己的代码是调用方,CWindow::GetWindowText 是被调用方;但是,以实际执行体(HMODULE)为单位的概念来讲,我们自己的代码和 CWindow::GetWindowText(它会被编译器安排到我们自己的代码领空中)都是调用方,而 Win32 API GetWindowTextLength 和 GetWindowText 所在的 user32.dll 模块才是真正的被调用方。在明确了这个事实之后,那个隐藏至深的隐患便不难察觉了——如果有另一个线程在调用线程执行到 GetWindowTextLength 和 GetWindowText 之间时改变了窗口的文本(增加了这个字符串的长度),那么调用线程所分配的内存则必然不敷其用,从而导致返回的窗口文本将会是一个被截断的结果。虽然这个场景有些极端,甚或可以说是吹毛求疵,但是,你无权就此而否认这个问题的存在。

再回头看刚才“某方”的问题。于 Win32 API 而言,我当然无能为力;但是,如果我有权来实现被调用方的话,我会使上面的步骤成为下面这个样子:

  1. 调用方向被调用方发起调用。
  2. 被调用方将数据加锁。
  3. 被调用方获取数据大小。
  4. 被调用方分配内存。
  5. 被调用方复制数据。
  6. 被调用方解锁。
  7. 返还执行领空。

如果用 C 代码来描述,可能是类似这个样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Callee Code
 
typedef void* (*Allocator)(size_t cb);
 
void* GetSomeData(Allocator alloc)
{
    static const char data[] = "Hello, World!";
 
    void *buf;
    size_t len;
 
    Lock();
    len = strlen(data);
    buf = alloc(len);
    memcpy(buf, data, len);
    Unlock();
 
    return buf;
}
 
// Caller Code
 
void *data = GetSomeData(malloc);
// Use data ...
free(data);

我习惯于把这里面的 Allocator 称作“内存分配器”。借助于这个名字显得比较高大上的东西,被调用方可以自由地按照需要来为调用方分配适当的内存,其好处有二:

  • 简化调用方的代码。
  • 保证整个调用的原子性。

读到这里,也许会有人给出另一个看似简化了的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Callee Code
 
void* GetSomeData(void)
{
    static const char data[] = "Hello, World!";
 
    void *buf;
    size_t len;
 
    Lock();
    len = strlen(data);
    buf = malloc(len);
    memcpy(buf, data, len);
    Unlock();
 
    return buf;
}
 
// Caller Code
 
void *data = GetSomeData();
// Use data ...
free(data);

当然,他的理由肯定会很充分:为什么不直接用 malloc 分配内存,而非要再引入一个“分配器”的概念呢?看我这么做多简洁,而且亲测有效哒!

条条大路通罗马,春风不度玉门关。虽然你那里貌似是没问题的,但你不能保证你在所有情况下都是没问题哒。简而言之,如果你是自己写的代码自己用,我顶多给你俩字儿的评语——“丑陋”;但是,如果你打算把 GetSomeData 这样的函数导出给第三方使用(比如作为软件插件的 API)的话,那么我就会毫不客气地给你的评语再加上俩字儿了,这俩字儿是“愚蠢”。

究其原因,C Runtime Library 虽然号称“标准库”,但这个“标准”其实只是一个松散的、处于“规格”一级的东西,它只规定了 CRT 中必须有些什么名字的函数,这些函数必须长成什么样子;至于这些函数的实现内容和方式,这个“标准”则并未做出任何的要求。这就意味着,对于某些涉及系统资源申请/释放功能的函数而言,跨越不同的 CRT 版本掺杂使用就有可能出乱子。想象一下,你的软件是用 Visual C++ 开发的,而某个插件开发者用的是 Dev-C++……如果说哪天你收到了一封来自他的骂街邮件,我一点儿都不会感到奇怪。

我不否认,有了内存分配器的参与,代码看起来确实不够简洁。不过,如果用 C++ 来实现的话,有了语法糖的帮助,也能得到一个还算不错、近乎优雅的解决方案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Callee Code
 
class Allocator
{
public:
    virtual void* Alloc(size_t cb) = 0;
};
 
void GetSomeData(Allocator *alloc)
{
    static const char data[] = "Hello, World!";
 
    Lock();
 
    size_t len = strlen(data);
    void *buf = alloc->Alloc(len);
    memcpy(buf, data, len);
 
    Unlock();
}
 
// Caller Code
 
class MyString : public Allocator, public std::string
{
public:
    virtual void* Alloc(size_t cb)
    {
        resize(cb);
        return const_cast<char *>(c_str());
    }
};
 
MyString str;
GetSomeData(&str);

这里插一句,此分配器其实就是 COM 架构中 IMalloc 接口的路数,只不过我根据需要将其裁剪了。即使现今 .NET 大行其道,但我仍然认为 COM 的设计思想是无有出其右者的。

最后再说说语法糖。在刚才的例子中,语法糖帮我们隐藏了一些冗繁的代码,从而使得调用代码看起来更加整洁悦目。比如,像 MultiByteToWideChar 这样需要遵循“二段式”调用步骤的函数,我的封装代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template < typename T, typename N = size_t, class R = std::vector<T> >
class DuoCall
{
public:
    static R Call(std::function<N(T *, N)> callback)
    {
        N size = callback(nullptr, 0);
 
        std::auto_ptr<T> buf(new T[size]);
        callback(buf.get(), size);
 
        return R(buf.get(), size);
    }
};
 
std::wstring ws = DuoCall<WCHAR, int, std::wstring>::Call([](PWSTR buf, int size) {
        return MultiByteToWideChar(CP_ACP, 0, "Hello, World!", -1, buf, size);
});

如你所见,正是 C++ 11 中的 Lambda 表达式赋予了我们这种可能,将本来要书写两次的 MultiByteToWideChar 减成了一次。

当然,这么写仍然是比较繁琐的。事实上,这个 DuoCall 类更适合被用于当成一个基类,从其派生出各个子类来进行特定的“二段调用”处理。考虑如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class MB2WC : private DuoCall < WCHAR, int, std::wstring >
{
public:
    MB2WC(UINT cp = CP_ACP) : m_cp(cp) { /* Nothing */ }
public:
    std::wstring Convert(PCSTR src)
    {
        return Call([=](PWSTR buf, int size) {
            return MultiByteToWideChar(m_cp, 0, src, -1, buf, size);
        });
    }
private:
    UINT m_cp;
};
 
MB2WC conv;
std::wstring ws = conv.Convert("Hello, World!");

订阅本站

7 Comments

  • At 2015.03.07 11:59, 油炸苹果 said:

    沙发

    • At 2015.03.09 14:49, 油炸苹果 said:

      除了你说的二段式的API外, 我还觉得类似 FindFirstFile, FindNextFile这类API也很有意思, 难道不能直接用类似vector.

      • At 2015.03.17 16:40, 李马 said:

        不明白你的意思,可试写一段伪码表明意图。

      • At 2015.03.26 11:21, 油炸苹果 said:

        来了, 学艺不精, 还请指点.

        我是在从Win32的角度想, 开发这个dll的人什么情况下需要写出 FindFirstFile 这类代码. 让用户取枚举一些值.

        是不是可以这样设计API

        FindAllFile( std::vector &FileVector)
        {

        FileVector.push_back( FILE ) ..

        }

        这样我用的时候就很方便了. 但是有个问题, vector 并不是系统自身的东西. 这样的代码是不会被设计成API的, 因为类似通用性的问题.

        比如当我写一个配置文件解析类时
        解析 key=value1, value2, value3 时
        我可能这么设计代码
        void GetAllKeyValue( std::vector &ValueVec )
        {
        }

        但是假设用户实现没有vector..我怎么写的更通用呢, 难道只有FindFirstValue这种方式.

        • At 2015.03.27 13:00, 李马 said:

          可以参考文中 Allocator 的例子,让用户提供自己的分配器,你负责来在每次获取到文件的时候调用这个分配器并向用户的结构内写数据。

        • At 2015.03.26 11:25, 油炸苹果 said:

          还有问题请教

          我在学习你的 PDL 的 iniparser, 我使用C++的STL, 好像他的迭代器不适合你那种实现方式. 就是迭代器是个直接指向原本链表元素的地址, 所以只要这个链表存在, 这个迭代器就会一直有效. 我用vc6写发现很难写. 求指导.

          • At 2015.03.27 13:01, 李马 said:

            1. LIniParser 不值一看,去看《键歇(1)》的那个吧。
            2. 别再用 VC6 了。

          (Required)
          (Required, will not be published)