善意提醒

如果您打开本站很慢,布局排版混乱,并且看不到图片,那么可能是因为您还没有掌握用科学的方法上网的本领。
显示标签为“编程”的博文。显示所有博文
显示标签为“编程”的博文。显示所有博文

2025-08-19

这外包实在是太垃圾了

图片由 Google Gemini 生成

老板之前找某个外包团队做的一个项目,今天把阿里云 RDS 搞爆了,找我来看。

看了一下,是下午两点半左右爆掉的。空间撑爆了被「锁定」了。看了下监控,把空间撑爆的是 temp_file,临时文件。
那个时间段有不少慢查询,找了 SQL 来看。一看到我就开始骂娘了:

select DISTINCT * from
        (select
        t.business_id id,
        any_value(t.type) type,
        t.`user` `user`,
        any_value((case
        when t.type='1' then '文章'
        when t.type='2' then '公告'
        when t.type='3' then '问答'
        end))  typeName,
        any_value((case when (select a.clone_num from tb_article a where t.business_id = a.id) is null then 0 else (select a.clone_num from tb_article a where t.business_id = a.id) end)) cloneNum,
        any_value((select a.title from tb_article a where a.id = t.business_id)) title,
        any_value((select a.content from tb_article a where a.id = t.business_id)) content,
        any_value((select a.publish_time from tb_article a where a.id = t.business_id)) publishTime,
        any_value((select a.essence from tb_article a where a.id = t.business_id)) essence,
        any_value((select a.`status` from tb_article a where a.id = t.business_id)) `status`,
        any_value((select a.top from tb_article a where a.id = t.business_id)) top,
        any_value((select count(c.content) from tb_comment c where c.business_id = t.business_id)) comments,
        any_value((select count(l.id) from tb_like l where l.business_id = t.business_id)) likes,
        any_value((select count(f.id) from tb_focus f where f.business_id = t.business_id)) focus,
        any_value((select a.pageview_num from tb_article a where a.id = t.business_id)) viewCount,
        any_value((select tba.auther from tb_article tba where tba.id = t.business_id)) userId,
        any_value((select m.auth_user_id nickName from tb_member m
        where m.id = (select a.auther from tb_article a where a.id = t.business_id))) nickName,
        any_value(t.browsed_date) browsedDate
        from tb_browsed_records t group by t.`user`, t.business_id ) temp
         WHERE  temp.user = 494 
        order by temp.browsedDate desc,temp.top desc limit 0,1

已经不想浪费我的时间了,找了 AI 来帮忙吐槽。以下是 Google Gemini的输出:

这个SQL看起来像是初学者写的,槽点确实不少。下面我们来一起“欣赏”一下。


槽点一:子查询泛滥成灾,性能杀手! 🔪

这个查询里,几乎每一个字段都用了一个独立的子查询来获取数据,像这样:

any_value((select a.title from tb_article a where a.id = t.business_id)) title,

这简直是性能灾难!数据库每查询一个字段,都要重新去 tb_article 表里查一次,而且还不是一次查所有需要的字段,是查一次 title,再查一次 content,再查一次 publish_time……这就好像你想买菜,不是一次把所有菜买齐,而是买完一个土豆跑回家,再回去买一根黄瓜,再跑回家……

正确的做法是使用 JOIN!将 tb_browsed_records 表和 tb_article 表连接起来,所有字段一次性查出来,效率高得多。

SELECT
  t.business_id AS id,
  t.type,
  t.`user`,
  a.title,
  a.content,
  a.publish_time,
  -- ...其他字段
FROM
  tb_browsed_records AS t
JOIN
  tb_article AS a ON t.business_id = a.id
-- ...其他连接

槽点二:DISTINCT + GROUP BY,多此一举

你看这个查询,外面有个 DISTINCT *,里面又有 GROUP BY t.user, t.business_id。

DISTINCT 的作用是去重,GROUP BY 的作用是分组并聚合。GROUP BY 本身就会对分组字段进行去重。所以,在外层加 DISTINCT * 是完全没有必要的,反而可能会增加额外的计算开销。这就像你把一堆苹果已经按品种分好类了,然后又再来一遍“去重”操作,完全是重复劳动。

槽点三:any_value 的滥用

这个查询里大量使用了 any_value。any_value 的作用是在 GROUP BY 聚合时,从一个组中任意取一个值。对于 t.type, t.user, t.business_id 这种已经在 GROUP BY 里的字段,直接写就行了,不需要加 any_value。

any_value((select a.title from tb_article a where a.id = t.business_id)) title

像这种对子查询结果使用 any_value 更是让人费解。它不仅没有解决任何问题,反而让代码看起来更复杂。

槽点四:重复的子查询,浪费资源

any_value((select a.auther from tb_article a where a.id = t.business_id))

和

any_value((select m.auth_user_id nickName from tb_member m where m.id = (select a.auther from tb_article a where a.id = t.business_id)))

这两个子查询都重复查询了 a.auther。完全可以在第一次查询到 a.auther 后,直接用这个值去查 tb_member 表,而不是再查一次。这就像你去问一个人的名字,问完后又回到原点,再问一次这个人是谁,然后再去找他朋友……

Google Gemini 还帮我重写了 SQL。写得挺好,执行速度至少快两个数量级。原来那个用了太多的子查询,就算逻辑上不该慢这么多,实际上就得慢这么多,而且还时间空间双杀。

这种写法,我也不认为是甲方逐步追加需求导致的。这就是不合格的程序员导致的,没有任何借口!

最后感慨一句:外包跟 AI 一样,你得要能驾驭。

2025-08-05

是谁动了我的 HDC?

最近工作上倒是查了不少问题,然而到了末尾都发现只是一些低级错误,完全不好意思拿出来说。但今天遇到一个,还算有一些意思,可以讲讲。


问题的表现是我们的软件上某些部位偶尔会「花」掉,显示的是之前覆盖在上面的内容。有经验的程序员一看就知道是 GDI 的问题,但到底是什么问题呢?

图片由 Google Gemini 生成

刚开始的时候,以为是 GDI 泄漏了。曾经有过一个例子,漏到了好几千,后来到了 1 万,触发了 CEF 的 CollectGDIUsageAndDie 被 Dump 了。还没到 1 万的时候,界面上的表现就是开始花,其实就是有部分 GDI 函数已经开始调用失败了。

然而这次并不是,GDI 对象数很正常。开发人员远程调试跟了一下,发现是 HDC 拿不到。CreateCompatibleDC() 得到了一个 NULL,因此 MemDC 创建不出来。


什么情况下 CreateCompatibleDC() 会调用失败呢?GDI 对象数不多,内存也很充足。软件中其它部位的 MemDC 是能正常创建的,因此全局性的因素都可以排除。什么光栅设备、显卡驱动之类也就不可能是原因了。
除此之外,就只剩下了一个可能性:CreateCompatibleDC() 传进去的 HDC 参数有问题。

试了一下,乱传一个不存在 HDC,的确会导致 CreateCompatibleDC() 返回 NULL。如果传的是个 NULL 进去,倒是还好一点。看来是窗口上的 HDC 有问题,当然也有可能是窗口本身就有问题。然而其它地方也在用同一个 HWND 创建 MemDC,一切正常。所以还是某些 HDC 有问题。

HDC 的值看起来并不奇怪,也没有什么好办法确认它到底有什么问题。HWND 还能用 Spy++ 来看,HDC 我是一筹莫展。还好可以 OutputDebugString。日志打出来,有意思的地方来了。

我看出问题了:当某个 HDC 失效的时候,它一定是被连续拿到过两次,中间没有 ReleaseDC() 过。这两次 GetDC(),都得到了同一个 HDC,肯定不正常。
我把 this 指针和 HWND 的值也加到了日志里面,这下看得更清楚了。两次 GetDC() 分别是不同的 HWND。而 HDC 失效之时,就是当它被最终 ReleaseDC() 的时候。只不过这次 ReleaseDC() 的 HWND 是一个旧的窗口,那个窗口此时应该已经被销毁了。


窗口 OnDestroy() 的时候,没能把它的 HDC 一并归还,这当然是我们软件中的代码错误,也是我们遇到的问题的直接原因。但故障现象的根本原因是什么呢?我们确实没有按照规范「一借一还」,至少窗口还「在世」的时候没有。不过 ReleaseDC() 难道不管三七二十一,只要有人拿着某个 HDC 来释放,它就答应吗?都不用看看 HWND 对不对得上吗?

微软 关于ReleaseDC()的API函数说明 在这一点上就有点语焉不详了。大概它没想到有人会这样去实践?的确也没人问这种问题,完全找不到资料,只好自己动手做了个实验:

HDC hDC = ::GetDC(hWnd);
int nRet = ::ReleaseDC(hWnd + 0x1000, hDC);

随后再在这个 DC 上用 GDI 函数画东西,确实画不出来。nRet 也的确是 1,按照微软的说法,返回值 1 表示 DC 被释放,这倒是没有骗人。我把 hDC 也加了个数字,然后再跑一遍,这次 nRet 变成 0 了。

所以说,微软是在 ReleaseDC() 的时候搞了个「容错」逻辑?只要 HDC 对得上,就给释放,不管 HWND 对不对得上号?我一开始跟 Google Gemini 探讨这个问题的时候,它还不相信,直到我告诉它测试结果。


回过头来看,为什么两次 GetDC() 能得到同一个 HDC 呢?
我们的主窗口,经历了销毁后重建的过程。在 OnDestroy() 的时候,没有及时执行 ReleaseDC()。但 Windows 可能认为,窗口不在了,DC 也就没了。于是另一个新建起来的窗口又通过 GetDC() 拿到了同一个 HDC。等到主窗口的 C++ 对象开始析构,调用 ReleaseDC() 的时候,新窗口拿到的 HDC 就被背刺了。系统的 DC 应该是在放一个池子里面,所以是有可能被重用的。这当然需要「运气」,也正因为如此,故障现象不是很稳定。

会遇到这种问题的人,应该不多。现在还在用 GDI 做开发的项目本来也就不多了。我在网上没能找到什么可以参考的信息,还好勉强算能够重现,就抓住机会解决了问题。经验值又 +1 了。

最后说明一下:我这个实验是用 VS2013 在 Win10 下面做的。不同的 OS 以及 VS 版本可能会有不一样的情况。毕竟是所谓的「未定义」行为。

2017-05-25

将 C++11 新特性用于代码优化

关于 C++11 的科普,在这里就不详细进行了,可以参考 维基百科 页面。即使是中文页面,我认为写得足够详细和系统了。

总之,C++11 对原始的 C/C++ 作出了在我看来是不算小的改动。有一些概念,放在以前的时代是绝对真理,在 C++11 推出之后,可能需要重新了解一下了。VS2013 对 C++11 的支持并不算「完美」,不过大部分「有用」的特性还是到位了。这里就以它为例,来谈谈如何把 C++11 的新特性应用到你的软件开发工作中来提升性能和开发效率。

本文提到的 C++11 的这些新特性,我大致把它们分为两类:一类是可以直接提升代码的性能表现的,我列在「性能优化」部分;另一类虽然不能直接提升代码的性能,但可以提升开发效率,便于更快地开发出可维护性更好的代码,我列在「非性能优化部分」。

另外,受作者水平所限,本文并不是对 C++11 在这些方面的完整的参考内容,仅仅作为一个引导来阅读吧。


性能优化部分

右值引用和 move 语义
C++11 引入了右值引用,支持了 move 语义。在我看来,这个变化的意义可能是 C++11 里面最大的一个。右值引用和 move 语义是什么,这里不展开。通俗一点地讲,这个特性使得程序员可以在必要的时候自行决定到底是深拷贝还是浅拷贝。对于大量的数据「搬运」操作,可以节省下不少时间。对于性能优化来说,意义重大。

其实就算没有右值引用,在 C++11 之前的时代也可以做类似的优化。C++ 程序员只要对于自己的资源管理类显式地提供深 / 浅拷贝版本的函数即可。不过这样一来代码工作量会比较大,程序会变得比较复杂,并且始终不是一个规范。现在这一切都不是问题了。

对于 STL 自己的类/容器,VS2013 已经做了足够的优化。例如,你可以通过:
string strA = std::move(strB);
来把 strB 的字符串动态内存部分直接给到 strA,速度比简单的赋值要快不少。当然,strB 就不再具有有意义的值了(这里例子中会变成空字符串)。当你 push_back 或 insert 一个 string 到容器里面的时候,如果 string 其实是一个临时变量,那么用 move 语义你也可以得到相当明显的性能提升。

如果例子里面 string 换成一个 map<string, string>,那么提升会更明显。总之,内存的分配和释放,以及 memcpy 操作统统被避免了。所以,理论上需要传递的东西越多,你得到的性能提升就会越显著。

就地部署(emplace)
C++11 对于常见的 STL 容器,都提供了一种能提升性能的数据置入方法,称之为「就地部署」。通过用就地部署取代原来的 push_back 或 insert 之类的操作,不再需要先构造再传递,而是由容器直接调用目标对象的构造函数来完成数据填充。

在某些情况下(T 提供了对应的构造函数时),这样可以避免一次拷贝构造的开销。而最差的情况(T 没有提供对应的构造函数),也最多不过就是与 push_back 和 insert 效果一模一样而已。所以我建议所有能用上就地部署的地方,都统统用上,无需太多考虑。

并且,就地部署与 move 语义相互并不冲突,而且是互有补充。move 语义解决深拷贝慢的问题,就地部署试图减少哪怕是浅拷贝的执行次数。两者配合起来效果更加完美。

散列表
在 C++11 里,不再需要通过第三方库来引入散列表(或者叫哈希表)了。STL 正式支持了四种散列表的实现,全部都冠以「unordered_」的前缀,以便与一些第三方实现相区别。

对于大多数用 map / set 实现的代码,只要简单替换容器就可以得到性能上的提升。map / set 基于红黑树(自平衡二叉树),时间复杂度至少是 log(N)。散列表版本的 map / set 提供常数级的时间复杂度,随着数据量的增大,无论是写入还是读取的性能都超过了红黑树版本的 map / set。

我个人的测试结论是:同是 set<string>,即使是小数据量,散列表版读取代价也只是红黑树版的约 60%;小数据量下,红黑树版写入略快,但在容器内数据量达到「万」级别的时候,散列表的写入速度也开始超越红黑树版(此为 Release 版测试结论,Debug 版在「百」级别即发生超越现象)。

所以我认为,只有在数据量很小,并且写入与读取的概率大致相当时,使用红黑树版 map / set 才在性能上可能有明显收益。其余情况,都建议采用散列表版本 map / set。当然,如果 T 是自定义类,并且你不愿意为它写散列函数,那就算了。


非性能优化部分

完美转发
C++11 中所谓「完美转发」的特性,其实是配合右值引用来使用的。如果为了支持右值引用,而不得不让自己的代码量变大一倍,那有些人可能就要望而却步了。完美转发其实是借用了模板技术,使得你可以只写一份代码,就可以兼顾(常量)左值引用与右值引用的情况。工作量更少,代码更简洁,出错的概率也就更低。

不过,采用模板技术的缺点就是:编译期展开。这一方面降低了编译器的效率,另一方面会导致头文件的包含关系变得不太容易整理。除此之外,还有一种我称之为「不完美转发」的替代解决方案,本质上是在性能上作出一定程度上还算可以接受牺牲,来换取代码简洁性,取得一个还算 OK 的平衡。我会另外写一篇 Blog 来介绍一下它。

类型推导
「类型推导」也就是所谓的 auto 类型。这个东西使用起来基本没有门槛。很多人可能最开始接触 C++11 就是通过它了。

这个的确是一个好东西,用来写 STL 的 iterator 类型再合适不过了。因为我们本来也不怎么关心 iterator 的具体类型。不过,仍然不建议滥用。如果到处都是 auto,阅读你代码的人会经常性地需要回顾才能知道一个变量的类型,特别是在你没有用匈牙利命名法的时候更是如此。

所以,我的建议是:当你觉得一个变量的类型写起来很麻烦,而你其实并不关心它的时候,放心地用 auto。并且,auto 变量的作用域不要太大,if / for / while 循环内的局部变量用它是最合适的。

基于范围的 for 循环
很多语言早就可以这样写了。而 C++11 现在也可以这样写了:
for (auto& stk : stocklist)
相比起:
for (auto pIter = stocklist.begin(); pIter != stocklist.end(); ++pIter)
孰优孰劣一目了然。何况后者通常还需要跟一句:
auto stk = (*pIter);
不过,如果是一个 map,你可能经常要取 pIter->first / second 之类。或者你打算在循环里面对 pIter 做 erase 操作,那还是用传统方式比较好。

空指针
用 nullptr 取代 NULL。我觉得最大的好处就是 nullptr 的颜色没有 NULL 扎眼。不过,由于 NULL 也表示 0,有的时候也表示无效句柄。我觉得对于所有指针类型的 NULL,置换成 nullptr 可能会对阅读代码有一定帮助。

角括号
C++11 的编译器现在可以识别 >> 到底是两个模板类的嵌套,还是 >> 运算符。因此写代码的时候就不特意空上一格,写多层模板类嵌套的时候就更美观一点。

不过,多层模板类嵌套,本来就不可能「美观」到哪里去。起码我是不建议太多此类的代码实践的。

初始化列表
vector 可用这样的方式来进行初始化:
vector<int> vecX = { 1, 2, 3, 4 };
的确是比以前省事了。也就是说,C-Style 数组的存在意义又少了一层。

统一初始化
struct 可以被这样初始化:
struct C
{
    int a;
    int b;
    int c;
};
C c{1, 2, 3};
class 的 public 成员也可以。
在某些喜欢使用各种结构体的代码中,这个特性可以让你少写一大堆构造函数。

通用智能指针
std::shared_ptr<T>,强在可以指向任意对象,缺点也由此而生:由于引用计数保存在 shared_ptr 中,因此对智能指针的赋值操作是线程不安全的。这个问题,有一篇Blog论述,我觉得写得不错,就直接引用不细讲了。从原理和测试数据来看,我认为这篇 Blog 是靠谱的。

所以,虽然 shared_ptr 很强大,但使用场合需要注意:单线程随便用。多线程下,赋值过程要注意。单对单没啥问题,最好不要出现左值右值交叉的情况(一个线程在 A = B,另一个线程在 B = C)。若因业务需求无法避免的话,要考虑当作临界资源加锁保护。实在不行,就写一个专用智能指针,把引用计数放在 T 里面,加锁保护,就不会有问题了。

正则表达式
与散列表类似,不再需要第三方实现,现在 C++11 也直接支持正则表达式了。我以前要找一个 Unicode 支持得好的 Regex 库真的是苦水一堆,现在有了官方支持真的是太好了。

2017-03-29

对 VS2013 下 C++11 的精准转发与通用引用的一点研究

在 C++11 中,允许用以下方式编写模板函数:
#include <iostream>
#include <string>
class A
{
public:
    template <typename T>
    void foo(T&& t)
    {
        T _t = std::forward<T>(t);
    }
};
int main()
{
    std::string s1 = "test";
    A a;
    a.foo(s1);
    std::cout << "1:" << s1 << std::endl;
    a.foo(std::move(s1));
    std::cout << "2:" << s1 << std::endl;
    a.foo("ok");
}
以上代码在 Visual Studio 2013 上测试通过。输出是:
1:test
2:
可以看到,模板函数 A.foo 只有一个声明和实现,但既可以接受左值,也可以接受右值。并且当 S1 被当作右值引用传入的时候,其值是确确实实被「丢弃」了。这就是所谓的精准转发(把 T 的类型准确地传递到使用者),以及通用引用(用一个 T&& 就可以表示所有的情况)。对于要写库的程序员来说,可谓是一个福音了。
然而,对于模板类,下面的写法看上去很好,但是编译会报错的:
template <typename T>
class A
{
    T _t;
    public:
    void foo(T&& t)
    {
        _t = std::forward<T>(t);
    }
};
int main()
{
    std::string s1 = "test";
    A<std::string> a;
    a.foo(s1);
    std::cout << "1:" << s1 << std::endl;
    a.foo(std::move(s1));
    std::cout << "2:" << s1 << std::endl;
    a.foo("ok");
}
编译后报错:
error C2664: “void A<std::string>::foo(T&&)”: 无法将参数 1 从“std::string”转换为“std::string&&”
with
[
    T=std::string
]
无法将左值绑定到右值引用
这大概是因为,T 的类型在 A<std::string> 的时候就确定了,因此编译器无法进行更多的类型推导。
那么怎么办呢?其实也不难,foo  函数像下面这样写就可以了:
template <typename T>
class A
{
    T _t;
    public:
    template <typename X>
    void foo(X&& t)
    {
        _t = std::forward<X>(t);
    }
};
在函数模板中用一个新类型就可以了。如果 T 跟 X 不一致,那么编译器反正会检查出来的。不用担心。
还有一个问题:有的时候我们会把 foo 的实现写在 class 的外面。那这个时候怎么办呢?
我本来想抛题目给大家去做。不过都到最后了卖关子也没什么意思,还是直说吧:
template <typename T>
class A
{
    T _t;
    public:
    template <typename X>
    void foo(X&& t);
};
template <typename T>
template <typename X>
void A<T>::foo(X&& t)
{
    _t = std::forward<X>(t);
}
标红的两行,只能是这个顺序。类的模板定义在上面,函数的模板定义在下面。颠倒过来,报错。要写在一行也可以,先左后右就行。但是要想把尖括号打开强行并成一句,报错。

2014-03-20

为什么不用动态内存分配?

在写这篇 Blog 的时候,我考虑了几分钟,在想要不要把标题写成《为什么有的程序员不喜欢用动态内存分配?》。最后我还是把那些修饰词和定语给删了。虽然那个标题更准确一点,但是本文基本上是一篇吐槽文,我还是比较喜欢这种反问句的感觉。

事情是这样开始的:
在工作中,遇到了别的同事以前写的一段代码。作用是显示从某些网上下载的文件的内容。文件下载完后,也在本地保存了一份副本,这样如果下次发现本地有副本,就直接显示不用下载了。
这基本上是一个类似浏览器缓存的功能,实现起来也不难。不过这次我碰到一个 Bug,有个文件的副本,在解析的时候报错了。
因为第一次下载的时候并没有报错,所以焦点就集中到这个缓存机制上。这里面有个值得关注的地方在于,大概是出于节省本地硬盘空间的考虑,本地的副本在保存时是压缩过的。于是问题可能出在两个地方:

  • 压缩算法有问题,压缩保存的时候,把文件给弄坏了。
  • 解压缩算法有问题,无法正确还原这个文件。

这套压缩 / 解压缩的算法,是开源的(zlib)。所以我认为问题不应该出在算法本身,更可能是用法没用对。调用代码大概是这个样子的:
#define chunk 16384
void compress_file(const char* source_file , const char* dest_file)
{
    unsigned char datein[chunk];
    unsigned char dateout[chunk];
    unsigned long datelong = chunk;
    unsigned long sourcelong;
    FILE* source;
    FILE* dest;
    source = fopen(source_file , "r");
    dest = fopen(dest_file, "w+b");
    while (!feof(source))
    {
        sourcelong = fread(datein, 1, chunk, source);
        compress(dateout, &datelong, datein, sourcelong, 1);
        fwrite(dateout, datelong, 1, dest);
    }
    fclose(source);
    fclose(dest);
}
void un_compress_file(const char* source_file , const char* dest_file)
{
    unsigned char datein[chunk];
    unsigned char dateout[chunk];
    unsigned long datelong = chunk;
    unsigned long sourcelong;
    FILE* source;
    FILE* dest;
    source = fopen(source_file , "r+b");
    dest = fopen(dest_file , "w");
    while (!feof(source))
    {
        sourcelong = fread(datein, 1, chun, source);
        datelong = chunk;
        if (uncompress(dateout, &datelong, datein, sourcelong))
        {
            fwrite(dateout, datelong, 1, dest );
        }
    }
    fclose(source);
    fclose(dest);
}
这段代码我也不打算在这里分析太多,问题很明显:代码编写的初衷,是想把文件分块处理。但每块数据压缩之后的大小并没有记录在压缩文件中,也没有采取一些诸如分隔符或区块补齐之类的定位措施,所以解压缩的时候实际上是无法忠实地按压缩时的分块来还原数据的。而出问题的那个文件,大小的确是超过了 16384,于是就被弄坏了。

这里就引出了一个问题:为什么要分块?
事实上,如果这段代码没有采用固定长度的 C-style 数组,而是用动态内存分配的解决方案,压根都不会需要分块,也就不会出现这个 Bug。当然,这只是解决这个 Bug 的方案之一。对分块压缩算法的理解有问题,也是造成这个 Bug 的原因之一。从这方面着手进行改进也是可以的,各有利弊而已。
但这不是我要表达的重点。在这个案例里,下载的文件并不会很大,几十 KB 就顶天了。我真正疑惑的地方在于:为什么不用动态内存分配?
可能的解释有:
  • 担心内存碎片问题
  • 担心忘记释放
  • 嫌动态分配内存麻烦
  • 习惯了这种固定长度缓冲区的写法
  • ……
也许还有别的原因,一时半会儿我是想不到了。

那么换个问题:什么时候该用动态内存分配?
这个答案会比较明确一点:
  • 空间大小不确定(运行期确定)
  • 栈上空间不够
  • 方便与线程外部传递 / 分享数据

在本文的这个例子中,文件的长度是不确定的,每块数据压缩后的长度也是不确定的。很明显,这就是属于应该用上动态内存分配的时候。
该用的时候不用,带来的恶果就是程序的可读性和可维护性就会变得差,出 Bug 的机会更高。毕竟固定长度的内存区域就一定要处理溢出问题。而且用固定长度去处理变长内容,要分块 / 分次,要做循环,要留意退出条件,测试时要覆盖 1 和 N……,这些都带来了不必要的开销。
还不如直接分配一块内存出来,只要到时候记得回收就 OK。性能方面值得担心的话,也可以自己优化内存管理,这是可以集中处理掉的事情。而那种用固定长度的栈缓冲区来解决此类问题的办法,好听一点叫做「质朴」,难听一点叫「土」。总不能每个需要动态内存分配的地方,都用这种土办法来应对吧。

我其实是觉得,有些程序员,会有意识(或下意识)地避免用动态内存分配。从写代码的时候就开始重视性能,是好事情,但写程序不能只看功能和性能。你写的程序,好不好懂,容不容易出问题,有没有定时炸弹,好不好改,方不方便扩展,这些也都是很重要的。性能不佳可以优化,这种代码级的性能问题(相比架构级而言)优化起来尤其容易。但其它的方面,要改善起来绝非一日之功。
往开了说,作为程序员,应该避免陷入「某个东西就是不好」的思维方式中。思维开始变得狭隘,是自身没法继续再提高(达到上限了)的标志之一。

2013-09-10

写了个批量测试服务器 Ping 值的小工具

不时有人问我要 hosts,但是很多时候也不敢贸然的给。担心泄漏是一方面。另外一方面,我这边可以用的 hosts,在别人那边可能用不了。我这边用起来很快的 hosts,在人家那边可能像乌龟爬。叫别人 ping 一下当然不是什么太麻烦的事情,但如果连试好几个 IP 都不行,有时候也是很头大的事。效率也很低下。

因此想,要是有个能批量 Ping,并且能很容易看到输出结果的工具,就好了。用批处理做个批量 Ping 是很容易的,但结果并不是特别一目了然。于是干脆自己动手做了一个。
启动时会加载当前目录下的 server.list 文件,文件中每行写一个地址(这几个地址有些人可能一看就知道是什么东西了)就可以了。写域名也是可以的,但我这里输出结果中并不包含域名解析的结果。
目前只打算简单做成这样,反正只当小工具用用。如果觉得网络不顺的时候,就启动起来跑一跑看看。然后也可以把一堆 IP 丢给别人自己校验去。

有需求的朋友可以去拿来用下。工具的下载地址:Dropbox镜像 Mega下载页面
为了减少 CRT 依赖性,上面的发布版本是静态链接的。如果想用动态链接版本,或者担心木马、病毒什么的,就下载下面的源代码自己编译吧。VC6 就可以。反正自己也没写多少代码,许可证什么的就懒得去弄了。
源代码下载地址:Dropbox镜像 Mega下载页面

2013-06-27

自己写了个帮助快速替换 hosts 的工具

替换 hosts 对于会的人而言并不是一件复杂的事情。不过仍然稍嫌繁琐:目录本来就比较深,还要涉及到文件复制和改名。稍一不留意还有搞错掉的可能。

两个月前,我终于无法忍受了,自己写了一个批处理来解决这个问题。人容易出错,人会烦,机器不会出错——如果你程序写对了的情况下。作为程序员,就应该有自己动手丰衣足食的觉悟。因此就有了 SetHosts.bat 的第一版:
@echo off
pushd %SystemRoot%\system32\drivers\etc\
copy /y %1 hosts
popd
只这样显然是不够的。在命令行下敲指令,这不是我的初衷。虽然 Windows 的 Drag & Drop 可以省很多事情,但我还想做得更多。把这个 BAT 注册成某个特定后缀的文件的默认打开程序不是很好吗?这样以后直接双击文件就可以完成替换了。

于是当时我自己用 regedit 在注册表上填写了关联文件打开方式的相关信息。现在我可以双击 hosts_cn.fkw 或 hosts_us.fkw 来快速地应用某个我事先准备好的 hosts 文件了。
应该说,自己用用,这样是足够了。不过我还想做的更好一点。让程序自己完成这个填写注册表的过程,不是更好吗?于是有了 SetHosts.bat 的第二版:
@echo off
if {%1}=={} goto install
if {%1}=={/u} goto uninstall
if {%1}=={/U} goto uninstall
pushd %SystemRoot%\system32\drivers\etc\
copy /y %1 hosts
popd
goto :eof
:install
set MY_TEMPREGFILE=%TEMP%\temp.reg
set MY_SETHOST=%~f0
set MY_SETHOST=%MY_SETHOST:\=\\%
echo Windows Registry Editor Version 5.00>%MY_TEMPREGFILE%
echo.>>%MY_TEMPREGFILE%
echo [HKEY_CLASSES_ROOT\.fkw]>>%MY_TEMPREGFILE%
echo @="Fuckwall.Hosts">>%MY_TEMPREGFILE%
echo [HKEY_CLASSES_ROOT\Fuckwall.Hosts]>>%MY_TEMPREGFILE%
echo [HKEY_CLASSES_ROOT\Fuckwall.Hosts\shell]>>%MY_TEMPREGFILE%
echo [HKEY_CLASSES_ROOT\Fuckwall.Hosts\shell\edit]>>%MY_TEMPREGFILE%
echo [HKEY_CLASSES_ROOT\Fuckwall.Hosts\shell\edit\command]>>%MY_TEMPREGFILE%
echo @="\"C:\\Windows\\Notepad.exe\" \"%%1\"">>%MY_TEMPREGFILE%
echo [HKEY_CLASSES_ROOT\Fuckwall.Hosts\shell\open]>>%MY_TEMPREGFILE%
echo [HKEY_CLASSES_ROOT\Fuckwall.Hosts\shell\open\command]>>%MY_TEMPREGFILE%
echo @="\"%MY_SETHOST%\" \"%%1\"">>%MY_TEMPREGFILE%
regedit /s %MY_TEMPREGFILE%
del /q %MY_TEMPREGFILE%
set MY_TEMPREGFILE=
set MY_SETHOST=
echo 处理程序安装成功
echo 您可以双击后缀名为fkw的文件来直接更换hosts
echo.
pause
goto :eof
:uninstall
set MY_TEMPREGFILE=%TEMP%\temp.reg
echo Windows Registry Editor Version 5.00>%MY_TEMPREGFILE%
echo.>>%MY_TEMPREGFILE%
echo [-HKEY_CLASSES_ROOT\.fkw]>>%MY_TEMPREGFILE%
echo [-HKEY_CLASSES_ROOT\Fuckwall.Hosts]>>%MY_TEMPREGFILE%
echo [-HKEY_CLASSES_ROOT\Fuckwall.Hosts\shell]>>%MY_TEMPREGFILE%
echo [-HKEY_CLASSES_ROOT\Fuckwall.Hosts\shell\edit]>>%MY_TEMPREGFILE%
echo [-HKEY_CLASSES_ROOT\Fuckwall.Hosts\shell\edit\command]>>%MY_TEMPREGFILE%
echo [-HKEY_CLASSES_ROOT\Fuckwall.Hosts\shell\open]>>%MY_TEMPREGFILE%
echo [-HKEY_CLASSES_ROOT\Fuckwall.Hosts\shell\open\command]>>%MY_TEMPREGFILE%
regedit /s %MY_TEMPREGFILE%
del /q %MY_TEMPREGFILE%
set MY_TEMPREGFILE=
echo 处理程序反安装成功
pause
goto :eof
这下不仅注册有了,注销(反安装)也有了。以后看到 fkw 后缀的文件,就可以快速地 Fuckwall 了。……于是今天方校长就请病假了。

如果整代码有困难,那么可以从下面两个链接之一进行下载:
Dropbox: https://dl.dropboxusercontent.com/u/44933633/SetHosts.bat
Mega: https://mega.co.nz/#!Bhx2yYoR!ElR26mi-iGqx5WU3YJks8ViINKEE_wlv1Y32Mil6UEU

2013-04-10

修改 GoAgent 客户端以支持 Mega

为了能用来访问 Mega,对 GoAgent 客户端代码做了略微的修改。不过首先要说明一下为什么会有这个修改。

Mega 是个总部位于 New Zealand 的网盘服务。服务器当然全世界都有,但至少在我这边 ping 值不好。严重的时候 600ms 以上,并且丢包。这样的话,不管本地有多少带宽,实际上也是不可用的。总不可能花上一整天的工夫来传一个 ISO 吧。
开着 VPN 会快,但流量和费用都是问题。于是很自然地想到了是否可以通过 GoAgent 之类的 GAE 代理来访问。Google 服务器与 Mega 之间的带宽应该是不成问题的,而 Google 服务器与我之间的速度也是我可以在一定程度上控制的。不过测试下来发现 GoAgent 不支持 OPTIONS 这种 HTTP Method,而且这个局限性是 GAE 导致的。GAE 只支持GET / POST / HEAD / POST / DELETE 这五种 HTTP Method。偏偏 Mega 在登录和上传下载的时候都会发 OPTIONS 请求,于是这个方案一度被搁置了。
后来 Mega 的速度进一步下降,有时候一整天都传不完一个 100M 的文件。于是这个方案又被我拿出来考虑。这次我准备绕开服务端的限制,直接从客户端下手。OPTIONS 请求涉及的数据量是很小的,文件传输用到的 CONNECT 之类才是主要的带宽压力。因此可以让客户端在遇到 GAE 服务器无法处理的 HTTP 请求时,直接将其发到目标服务器。由于 Mega 目前还没有被 GFW 给 IP 黑洞,因此应该可以在一种「混合模式」下被通过 GoAgent 访问到。

下面介绍一下修改方法,以 GoAgent 2.1.15 版(2.1.17 还需要服务端改动才行)为例:

首先在 local/proxy.py 中找到这两行:
"""rules match algorithm, need_forward= True or False"""
need_forward = False
第一行是注释。而下面这个 need_forward,就是用来控制是否把一个请求直接送出(FWD),而不是送去 GAE 服务器进行中转。

在后面的 if 语句前,加入这样的内容:
if self.method != 'GET' and self.method != 'POST' and self.method != 'HEAD' and self.method != 'PUT' and self.method != 'DELETE':
    if host not in http.dns:
        http.dns[host] = list(set(http.dns_resolve(host)))
    need_forward = True
非 Python 程序员也应该很容易读懂这段代码,不过要提醒一下:Python 中缩进是很关键的,改代码时一定要用空格正确地缩进。
最后,别忘了把下面那个原来的 if 改成 elif。

这样改过之后的 GoAgent 客户端,在遇到 GAE 服务端不能处理的那些 HTTP 请求类型时,就会把它们直接发到目标服务器上。
从理论上讲,这个小修改不会对 GoAgent 的翻墙能力有任何的增加,但可以让它具有更大的适用范围。一些原来不能用 GoAgent 访问的站点(比如上面提到的 Mega),现在可以用它来访问了。GAE 的流量按天计算(VPN 一般按月,VPS 也是)。并且因为可以使用多个 GAE 账号,因此流量基本上是免费且无限的。Mega 那 50GB 的大空间,终于具有一定的可用性了。

2011-11-29

汇编还是不能丢啊

最后还是通过看汇编代码定位了问题。有位同事在用数组的时候不检查下标,结果在并发状态下刷到了 -1。不过问题的根源还是线程安全性。底层数据引擎设计时并没考虑并发问题,因此才会在正常工作的时候变出非法的数据出来。

借此机会又再次温习了汇编语言,大学时候学的 8086 / 8088 汇编指令虽然早已过时,但思想还算没忘。那些关于现在的大学念了白念的说法,我觉得还是需要探讨。

之前用 tds2dbg 完成的工作也有价值,帮我把代码段确定了。C++ 代码能定位到就这一两句,那么再来对照汇编指令就比较好理解了。

另外,今天还学习到了 LEA 指令的相关知识,顺便网摘一篇对我有点帮助的博文:
http://www.cnitblog.com/textbox/articles/51912.html

掌握了更好的 BCB 程序调试手段

Borland C++ Builder 5 编译的程序没有 PDB,因此要通过 Dump 文件来分析故障原因就太坑爹了。没有符号文件的话,汇编看起来相当痛苦。
上次查过,有个办法是使用 map2dbg,把 map 文件转换成 dbg 文件,这样 windbg 也能够加载符号文件用于调试。不过 map 里面只有函数名称,没有代码的行号,所以调试起来还是不是很方便。如果断点是在一个长长的函数里面,而且没有嵌套调用什么函数,那么对于汇编功底不深的我也是一样的郁闷。

今天下了点决心要解决这个问题,否则调试效率太低了。
感谢万能的 Google,这次我知道了有个开源项目叫 tds2dbg。用法和 map2dbg 类似,生成的 dbg 文件的确可用,而且在正确的行号上指出了我遇到的问题。这样,Crash 就不再是一个问题了。

值得注意的是:BCB 编译选项中,必须打开 Compiler->Debugging->Line Number Information,以及 Linker 里面的 Create debug information。否则生成的 dbg 文件没法用。

2011-11-14

遇到 Bug 就像踩了地雷

大约是八月份的时候,应公司的要求,设计了一个给各产品线通用的日志模块。简单而言,也就是做一个导出 C 函数的 DLL,实现通用的日志读写和管理功能。设计是我做的,实现就交给了另一个同事。然而这个同事没过多久就离职了,因此交接时把草草地写完的代码又回到了我这里来。

平心而论,这段代码写得不怎么样。估计这位同事当时已经心不在焉了,只想早早结束了事吧。代码中充满了他此前做过的一个 BCB 项目的风格,主要的功能是用 C 风格的代码来实现(例如文件读写等),而工程又是一个 VC6 的 Win32 DLL,里面写了自己的类等等。我看了之后第一感觉就是想推了重写,好不容易才压制住这种想法。
当时我给自己安排了两天来接收并改造这些代码,后来拖长到了三四天。其实现在想想还不如重构。给我的代码功能测试都通不过,里面 Bug一大堆。我这人又是有一点代码洁癖的家伙,遇到空格缩进什么的都忍不住花时间去调。最后终于弄好了,真有点不想再去碰了,虽然我知道值得改进的地方还很多。

结果今天就碰到了一个藏在其中的 Bug。

这个日志模块被设计为保存加密日志,目的嘛……是为了防止用户抵赖。加密就不能全文加密,否则每来一条日志都得解密再加密,量稍微一大就死翘翘了。差分加密是必须的。设计的时候是对每一条日志的内容进行加密,只留下必要的信息放在每一条的头部以便索引。从性能和安全性方面权衡,当时选择了 AES 算法。
代码交到我手上时,之前那个同事大概已经从网上找了一段 AES 算法封装好了。既然能实现加密解密的功能,我也就没往里面细看。谁知道这玩意儿是线程不安全的。今天专拣双核机器报错,从 fseek 到 fopen 查了一圈,终于怀疑到这个 AES 算法上。浪费了约两个小时时间。

我估计我有时间可能真得把它给重写了,里面指不定还有什么问题。

2011-10-24

VC 和 BCB 那点事——DLL 导出函数的结构体参数

还是那个项目,VC 写一个 DLL,导出若干 C 函数,BCB 来调用。实际情况比这个复杂,不过与我这次要讲的这个问题无关,所以在此省略了。

在解决了上次遇到的虚函数表顺序问题后,继续往下调试,又碰到了一个很怪的问题。有一个导出函数,一调用便崩溃。进去一看吧,崩溃点的代码别的导出函数也在用,没什么不对。虽然改改代码,可以做到不抛出异常,但该导出函数返回的值又不正常。反正它就是干不了想干的活。
该函数本身很简单,因此怀疑不是内部逻辑导致的问题。通过对比,发现该函数与其它工作正常的导出函数有一个明显的区别:它返回了一个自定义的结构体作为返回值。函数的声明大概是这样子的:
LONDATEEX __stdcall GetDataDate( USHORT sMarket);
由于别的可能性被一一排除,因此焦点慢慢移到这个情况上来。查了查网上的信息,发现有一篇文章(被墙,由此可见 GFW 的反动性质)提到了跨模块调用时的内存管理问题,并据此总结了几条规则。其中一条,便是不要在跨模块函数调用中使用类或结构体作为参数或返回值类型。

该文章在这一点上略有阐述:因为内存管理模式不一样,所以类和结构体这种可能会进行内存分配、回收的数据类型,一旦用作跨模块调用的参数或返回值,就会导致不确定的后果。我认为这个解释是合理的。我之前也已经想到了这一点,不过接下来还有。
该文章还提出了改进建议——如果一定要作为参数或返回值传递,那么应该采用所谓的纯结构体,即只包含了简单数据类型的结构体,构造、析构时不涉及内存分配和释放。该文认为,这种纯结构体的数据类型,可以用于 DLL 导出函数的参数或返回值。碰巧了,我这次这个结构体,就是个纯结构体。那么按照这篇文章的理论,不应该有问题才对。

不弄明白这个问题,始终是不甘心,于是我动手做实验了。先用 VC 写了一个很简单的 DLL,声明了一个最简单的结构体,包含两个 int 类型的变量。然后用 BCB 来调用。并且,在做这个实验的时候,我特地注意避免了字节对齐不一致的问题。
嗯,结果是什么呢?调用完成了,可无论传进去的参数值还是传出来的返回值,都不对。同样的函数声明方式,都用了 __stdcall 的另一个只包含简单数据类型作为参数和返回值的 DLL 导出函数,工作正常。

两相对照,显然,就算是所谓纯结构体,也不见得能够放心地用于跨模块的函数调用。至少,在 VC 和 BCB5 之间,是会有问题的。别人说的,不一定就是对的,至少不一定全对。尽信书不如无书。

解决方案也很简单,指针是一个简单数据类型,把结构体的指针作为参数传进去就行了。比如:
BOOL __stdcall GetDataDate( USHORT sMarket, LONDATEEX* pDate);
还有别的办法也行,比如利用 COM,或者通过操作系统级别管理的对象来传递数据。总之别让两种编译器编译出来的东西去分别猜对方是怎么管理内存的就行。

2011-10-21

VC 和 BCB 那点事——虚函数的顺序问题

因为工作需要,编写了一个 C++ 类,供一个 VC 下的 DLL 使用。用法类似回调对象,将 C++ 对象的指针传给 DLL 导出的函数。类里面要被用到的函数全是虚函数,父类是个彻底的抽象类,由 DLL 的开发人员提供 .h 头文件。VC 和 BCB 在虚函数表的实现上是一致的,因此可以跨模块调用。

大部分虚函数的调用测试都很顺利,但在一个函数上遇到了麻烦。不,应该说是两个。在测试过程中发现,当 DLL 想要调用我提供的 C++ 类的 FunA 函数时,总是错误地调用到了 FunB 函数。反过来也一样,结果就好像是 DLL 把 A 函数和 B 函数搞反了。
class A
{
    ……
    virtual FunA() = 0;
    virtual FunB() = 0;
    ……
};
既然是按照虚函数表来调用,那就跟函数名无关。这一点很快就得到了证实。现在唯一剩下的就是顺序问题。根据资料,虚函数表里面的顺序是根据函数的声明顺序来排布的,也就是说 .h 头文件决定了顺序。调换了一下顺序,果然就正常了,可为什么会反呢?
其实不能只看这么一点儿代码,这个问题和上下文有关:
class A
{
    ……
    virtual FunB(int J) = 0;
    virtual FunA() = 0;
    virtual FunB() = 0;
    ……
};
最后是 CSDN 上一篇博文揭示了这个问题:VC 会把重载函数给排在一起,不管中间有没有插队者。参见:http://blog.csdn.net/doudouhuy/article/details/4348531
也就是说,最终的顺序实际上是:
class A
{
    ……
    virtual FunB(int J) = 0;
    virtual FunB() = 0;
    virtual FunA() = 0;
    ……
};
而 BCB 大概不会做这个改动,因此调用到的函数就和预期的不一样了。

最后说一下。为了避免遇到类似问题,建议在进行跨模块的虚函数调用的时候,彻底避开重载函数出现的情况。把所有函数的名称都声明得不同,就不会轮到编译器来干扰了。

2011-10-08

都是负数惹的祸?

十一长假之后第一天上班,继续调试那位还在婚假当中的同事留下的代码。上午查到的一个 Bug 让我相当无语。不过鉴于自己如果思想在开小差的话也很可能会犯类似错误,故记录下来警醒自己。

看看这段代码:

#define ST_ERR -1
……
BYTE result = ST_ERR;
……
if (result == ST_ERR)
    return errmsg;

事实上,这个if语句根本就没得到执行。即使 result 在赋值了 ST_ERR 之后从未改变,代码在执行时仍然跳过了判断,继续执行下面的代码。也就是说,result 明明赋值了 ST_ERR,但却不等于 ST_ERR。

难道遇到鬼了,result 中间被篡改了?溢出过?

好吧,其实 result 的确不是 -1,而是 255。看看 BYTE 的定义就清楚了。BYTE 并不是 C/C++ 内建的数据类型。在 Win32 平台上,它通常都被定义为 unsigned char。

到这里就明白了,unsigned 当然不可能有负数。而 ST_ERR 因为用了宏定义,当做常量使用的时候并不会自动转成非负的 unsigned 形式,这样当然不可能相等了。

说穿了,不是负数惹的祸,而是「宏」这个东西惹的祸。如果用 const 常量,因为常量的定义上有类型信息存在,就不会有这个问题了。

当然,这里无意评价宏与 const 常量的好与坏,单纯指出问题而已。目前这个公司的代码,宏用得不少,难保会有菜鸟程序员出这种错,隐患啊!

2011-10-04

初始化,真的很重要

刚花了大半天的时间解决掉一个 Bug,再次证明了变量初始化的重要性。照例,还是先来看一小段代码:

map<string, TEOBJ> telist; // 全局对象
……
TEOBJ spte(3);
telist["debug"] = spte;

很简单吧?按道理这点代码应该没有什么问题。不过在最后一句中却抛出了异常,发生了崩溃。负责这部分代码的人昨天就请了假回家了,节后还有婚假,一走要近二十天,只好自己动手找原因。

这里的 TEOBJ,是一个自己定义的类。在我的这个案例中,它是一个底层库里面定义的数据对象类,用在了很多地方。基于这个前提,我首先判断这个 Bug 的故障点在赋值号的左边。

可是左边也是个 std::map 的相当标准的用法,STL 出错的可能性恐怕比我们自己的底层库更低,而且这个用法我以前也用过很多,没有出过什么问题。怀疑的眼光于是又回到了赋值号的右边。

那么到底是哪一边呢?我也拿不准了,于是排除法上马。通过几轮替换,我终于把问题基本定性为「由 TEOBJ 引起,但需要在赋值到 map 的 [] 运算符时产生」。

既然说到赋值,那么就该看看 TEOBJ 的相关声明了:

class TEOBJ {
public:
    int GroupID;
private:
    int GroupCode;
public:
    TEOBJ::TEOBJ() {};
    TEOBJ::TEOBJ(int nGroupID) {
        GroupID = nGroupID;
        GroupCode = TDataStore::Groups[GroupID]->Code;
    }
    TEOBJ::TEOBJ(const TEOBJ& r) {
        GroupID = r.GroupID;
        GroupCode = TDataStore::Groups[GroupID]->Code;
    }
    const TEOBJ& TEOBJ::operator=(const TEOBJ& r) {
        GroupID = r.GroupID;
        GroupCode = TDataStore::Groups[GroupID]->Code;
    }
};

这个 TEOBJ 重载了拷贝构造函数和赋值运算符,在其中通过查询一个数据字典确保 GroupCode 有准确的取值。

首先,赋值运算符重载这里的返回值有点小问题。const 引用类型的返回值与习惯上的 non-const 引用类型的返回值有所区别。不过这个最多造成编译时语法检查上的问题,不至于引起指针错误。

其次,就是这个 TDataStore::Groups[GroupID] 的数组的使用值得怀疑了。

实际案例比这个情况要复杂一些,而且我对于 BCB 的使用还不算熟悉,调试基本靠 OutputDebugString。问题就诡异在,往 TEOBJ::operator=() 里面只要任意加一行 OutputDebugString,故障就消失了。搞得好像是野指针一样。

我在此浪费了很多时间,走了不少弯路。后来,一咬牙用上了 windbg,配合 map2dbg,好歹是得到了 CallStack。但是 map2dbg 之后得到的符号名称又和我以前看到的有些不同,于是又错判了位置。

最后,我终于在 windbg + 排除法的协助下找到了问题。问题根本不在赋值运算符重载函数中。尽管这里代码中明显是用到了它,但它并没有出问题。抛出异常的是拷贝构造函数。这个就要从 map::operator[] 的实现说起了。

Borland C++ Builder 5 里面的 std::map,其实现代码在 map.h 里面,是一个 inline 函数。挖出来一看,倒也很简单:

mapped_type& operator[] (const key_type& k) {
    value_type tmp(k,T());
    return (*((insert(tmp)).first)).second;
}

可以看到,这里先生成了一个临时变量(其实本例中就是 pair<string, TEOBJ>)。在生成这个临时变量的时候,采用了无参数的 TEOBJ 构造函数。此时 TEOBJ::GroupID 还是没有初始化的,在 Release 版本中它可能是任意值(TEOBJ 所在的底层库就是 Release 编译)。

实际使用时,TEOBJ 都通过 TEOBJ::TEOBJ(int nGroupID) 来初始化,因此不会产生这种未被正确初始化的实例。但在 map::operator[] 中,由于是「先插入空值,再传出引用用于赋值」,这个时候就使得拷贝构造函数被调用,于是产生了类似的访问野指针的效果,导致故障的出现。

通过在拷贝构造函数中用 OutputDebugString 输出 GroupID 的取值,确认了这一点。

至此,真相大白。总结经验教训如下:

  1. 变量的初始化很重要,一个也不能放松,不能因为眼下不初始化也不会出问题,就放松警惕。做底层库的人尤其应该重视——你的代码不只是你一个人在用!
  2. 数组的下标,或者说指针的偏移量,这种变量与指针基本上是同样的性质。要提防野指针,它们也要算上。
  3. 调试手段无所谓牛刀不牛刀,好用就用,不要因为感觉问题小、简单,就先用笨办法尝试,直到一筹莫展了之后才考虑别的办法。如果我一开始就用 windbg,时间上可能会节省更多一些。

在 STL 容器中删对象一定要小心

先来看一段代码:

typedef map<string, string> Dict;
Dict eventmap;
for (Dict::iterator pIter = eventmap.begin(); pIter != eventmap.end(); ++pIter) {
    if (pIter->second == "no")
        eventmap.erase(pIter);
}

熟悉 STL 的人都知道,这段代码是错的。出问题的可能性不是 100%,但是相当大,而且可能会是那种不一定能稳定重现的问题,往往会搞得人很恍惚。

自己很早以前就吃过这个亏,那时候还不太会用 STL。当定位到这一段代码后,凭直觉也觉得如果这样删除对象,那删除之后的 pIter 是不是正确的?可能很难讲。毕竟学过「数据结构」,大概能猜到各种容器里面的信息是如何组织的。

昨天又看见同事在这样写代码。可能他对 STL 也不熟。至少我目前对于 erase 这个单词已经产生了足够的警戒,一旦要用的时候就会想起吃过的亏来。

正确的代码应该是:

typedef map<string, string> Dict;
Dict eventmap;
for (Dict::iterator pIter = eventmap.begin(); pIter != eventmap.end();) {
    if (pIter->second == "no")
        pIter = eventmap.erase(pIter);
    else
        ++pIter;
}

另外,昨天发现 Borland C++ Builder 提供的 STL 和 VC 有点区别,map::erase 没有返回值,因此不支持这种写法。那么还有另一种办法:

        eventmap.erase(pIter++);

这样就可以了。相比之下,这种写法可能适应性更强一些。

2011-03-29

CWebBrowser2 打开 PDF 后退出时崩溃问题的解决

用嵌入在对话框中的 CWebBrowser2 控件打开 PDF 文档后,主程序退出时抛了 Access Violation。扔异常的是 ACRORD32.DLL,ADOBE 自己的玩意儿,调用栈中也看不出什么,主程序看来都快退完了,显然和什么东西没关干净有关。

照例,国产没货。最后在这里找到了:
以上方法,在 VC6 sp6 / WinXP 的 Debug 和 Release 编译上都试过。除此之外就不知道了。
据说,问题和 Adobe Reader 9 有关。V8 没有这个问题,因为 V8 是单一实例,而 V9 不是。照此一来,内存泄漏可能也是难免的。

2007-04-14

三天居然写了上万行代码

昨天忙完工作之后,大出了一口气。CheckIn 的时候数了数 project 下面的文件个数,92 个,一半 .h 一半 .cpp,除去某些系统生成的文件,也就是说,我自己写了 40 个对象。

感觉写了不少代码,但不知道到底写了多少。拿源码统计器算了一下,居然有一万一千多行,感觉比较吓人。十万行代码的系统已经可以算得上中型系统了,我三天就码了这么多。当然,不靠 Ctrl-C/V 肯定是完不成的。
一度有些怀疑这么多代码是不是设计有些糟糕。不过再想想,确实各个对象之间的代码已经很难再复用了。40 个对象有一半以上都是继承出来的多态对象,这还省了不少事,否则代码量还要再翻一番。虽然已经抽象了许多共性出来,但不同的信息还是有一些不同的数据结构,再加上对关联依赖的处理,光 SQL 都够写好久。

有些好奇,去网上查了一下别人的编码速度。结果有人说一天 1000 行都被人骂吹牛。心里感觉挺复杂,能做到别人很少做到的事情,固然有些得意,然而编码这种事情也并不是那么值得吹嘘的。好在设计也是我做的,还不至于沦落到 IT 基层民工的地步。

直到今天做完了测试,才开始有点得意。这样的代码量,Bug 却只有这么点,起码说明我编码能力并没有退化。测试结果与需求的对应也让我很满意,这说明设计上也做得不错,至少是起到了应有的效果。这个苦力当得也不是没有价值。

2006-07-05

关于表格内文字换行的再研究

通常,搞 Web 开发的都会遇到这个问题,就是苦心设计的表格被讨厌的一连串英文字符给撑破了。最常见的是自己开发的论坛或留言板,如果有一个情绪激动的家伙打了百来个英文的感叹号,那往往就会出现内容在这些感叹号上不换行,结果让表格撑破,页面变得乱七八糟。

解决这个问题的办法,相信很多朋友都知道了。通过 CSS 中指定几个样式,可以比较满意地解决这个问题。一个是 word-break:break-all,一个是 word-wrap:break-word,表格中的文字则还有一个是 table 标签的 table-layout:fixed 样式。这三个样式给定之后,就不会再遇到表格被文字顶破的问题了。

稍稍解释一下上面三个样式。word-break:break-all 是让英文单词中部的换行成为可能,比如

beautiful

word-break 样式还有另外两个取值。一个是 normal,就是通常默认的,对中文断字而对英文单词不断的情况。另一个是 keep-all,是对中文,准确地说是亚洲文字也不断字。然而,和字面上的理解意思不同,keep-all 并不能让一个英文句子不被换行。要做到这一点的方法,稍后我会谈到。

word-wrap:break-word 是让连续的符号(如 ! 号)之间进行断字成为可能。这是解决那种一大串感叹号造成的问题的一个关键。没有这个样式的话,对连续符号的断字不会发生。

另一个关键是 table-layout:fixed。它指明说表格不要去试图自己计算宽度,就按照 HTML 中定义的宽度来显示就好。只有当 table-layout:fixed 和 word-wrap:break-word 同时指定时,针对连续符号的断字才会正确发生。缺少 table-layout:fixed,断字不会发生,表格仍被撑大。缺少 word-wrap:break-word,则表格不会被撑大,但断字不会发生,超过显示区域的内容将被 hide。

好了,知道了如何让所有内容都断字换行之后,另一个问题来了:如何让所有内容都不断字换行?

我们知道,即使指定 word-break:keep-all 样式,也无法阻止一个英文句子在空格处不被换行。而且汉字中的某些标点符号,浏览器也会很「智能」地把它给换行掉(起码IE会,这就足够了)。然而,一个好消息是,虽然要求不断字的情况远比要求断字要少见,但它的实现方法却相当简单。你只要把内容用 <nobr></nobr> 标签对括起来就行。被 <nobr></nobr> 标签对括起来的内容,浏览器绝对不会对它进行换行。只要记住这一点就很好办了。

不过,想让内容不换行的同时,通常并不希望表格宽度因此而变得不确定。因此,常常也需要指定一个 table-layout:fixed 的样式。这样,就可以让表格宽度维持设计时的大小不变,或者通过指定相对宽度来维持一个固定的比率,而并不受单元格中内容的任何影响。在设计自适应屏幕宽度的标题列表表格时,这个技术也许会有些用处。

2006-06-30

关于 fstream.open() 中使用 in|out|app 模式打开文件总是失败的问题

其实是以前找到的一个问题了,不过今天提到了编译器的版本,突然想起了它。在国内的网站上似乎很难搜索到关于这个问题的报告,因此写出来供分享。

事情的起因是服务器版本的升级。以前我们的程序都是在一台 RH 7.3 的内部服务器上编译的。编译之后的程序拿到正式服务器(RHEL AS 3.0)上使用非常正常,因此一直以来都是采取的这样的方式。
后来新增了一台服务器,是 x86_64 的 CPU,我们就打算安装最新的 RHEL AS 4.0 Update 3 for x86_64 版本,以获取最新的技术带来的优势。虽然最终因为短期内 Oracle 中的数据和设计无法升级到预计的 10g Release 2 for x86_64 版本,而导致升级计划暂时搁置,但在升级过程中,就发现以前的代码出现了许多问题。
当然,2.96 的编译器和 3.4.x 的编译器之间,肯定是有着不小的差别,因此代码会有问题这早就在我们的预料之中。大部分的错误都是容易发现和容易排除的(另文介绍),但其中有一个棘手的问题,就是本文要说到的,fstream.open() 的打开模式问题。

在以前的代码中,我们一直使用 in|out|app 的模式来进行日志文件的打开。采用这种模式,如果文件不存在,那么系统会创建它,如果文件存在,那么打开它并从最后面开始写信息,同时也可以从中读信息。可以说,针对那种以当前日期命名的日志文件,这种打开模式组合是再合适不过了。
然而,编译器升级之后,我们发现这样的打开模式再也打不开任何的文件了。文件既不会被创建,也不会被打开,即使是这个文件已经存在也不行。去掉 app 或去掉 in,都可以正常打开文件,但是这样就牺牲了追加或是读取文件的能力。相比而言,out|app 的打开模式对于我们的日志类来说应该是可以接受的,但是为了了解这个问题的根源,我们还是花了不少力气。

还是 Google 好用。当我们发现这个问题确实是编译器的升级带来的,与操作系统内核、文件系统读写权限、路径/文件名等因素无关之后。我们很快就在 Google 上搜索到了这篇文章:
http://gcc.gnu.org/ml/gcc-bugs/2002-04/msg01055.html
这位老兄发现了和我们类似的问题,并上报给了 gcc 那边。当然,他的工作比我们要做得细致得多。他仔细测试了多种打开模式的组合,并在多种平台、多种编译器版本上做了测试,并得到了结果。没有耐性点开帖子看或无法访问国外站点的用户可以看看下面这个测试结果:

GCC 2.95.2 i686-pc-linux-gnu
open("ios::in", O_RDONLY|0x8000)
open("ios::out", O_WRONLY|O_CREAT|O_TRUNC|0x8000, 0664)
open("ios::in|ios::out", O_RDWR|O_CREAT|0x8000, 0664)
open("ios::in|ios::ate", O_RDONLY|O_CREAT|0x8000, 0664)
open("ios::out|ios::app", O_WRONLY|O_APPEND|O_CREAT|0x8000, 0664)
open("ios::out|ios::ate", O_WRONLY|O_CREAT|0x8000, 0664)
open("ios::out|ios::trunc", O_WRONLY|O_CREAT|O_TRUNC|0x8000, 0664)
open("ios::in|ios::out|ios::app", O_RDWR|O_APPEND|O_CREAT|0x8000, 0664)
open("ios::in|ios::out|ios::ate", O_RDWR|O_CREAT|0x8000, 0664)
open("ios::in|ios::out|ios::trunc", O_RDWR|O_CREAT|O_TRUNC|0x8000, 0664)
GCC 2.95.2 sparc-sun-solaris2.8
open("ios::in", O_RDONLY)
open("ios::out", O_WRONLY|O_CREAT|O_TRUNC, 0664)
open("ios::in|ios::out", O_RDWR|O_CREAT, 0664)
open("ios::in|ios::ate", O_RDONLY|O_CREAT, 0664)
open("ios::out|ios::app", O_WRONLY|O_APPEND|O_CREAT, 0664)
open("ios::out|ios::ate", O_WRONLY|O_CREAT, 0664)
open("ios::out|ios::trunc", O_WRONLY|O_CREAT|O_TRUNC, 0664)
open("ios::in|ios::out|ios::app", O_RDWR|O_APPEND|O_CREAT, 0664)
open("ios::in|ios::out|ios::ate", O_RDWR|O_CREAT, 0664)
open("ios::in|ios::out|ios::trunc", O_RDWR|O_CREAT|O_TRUNC, 0664)

GCC 3.0.4 i686-pc-linux-gnu or sparc-sun-solaris2.8
open("ios::in", O_RDONLY)
open("ios::out", O_WRONLY|O_CREAT|O_TRUNC, 0666)
open("ios::in|ios::out", O_RDWR)
open("ios::in|ios::ate", O_RDONLY)
open("ios::out|ios::app", O_WRONLY|O_APPEND|O_CREAT, 0666)
open("ios::out|ios::ate", O_WRONLY|O_CREAT|O_TRUNC, 0666)
open("ios::out|ios::trunc", O_WRONLY|O_CREAT|O_TRUNC, 0666)
open("ios::in|ios::out|ios::ate", O_RDWR)
open("ios::in|ios::out|ios::trunc", O_RDWR|O_CREAT|O_TRUNC, 0666)

Sun CC 5.0 sparc-sun-solaris2.8
open("ios::in", O_RDONLY)
open("ios::out", O_WRONLY|O_CREAT|O_TRUNC, 0666)
open("ios::in|ios::out", O_RDWR)
open("ios::in|ios::ate", O_RDONLY)
open("ios::out|ios::app", O_WRONLY|O_APPEND|O_CREAT, 0666)
open("ios::out|ios::ate", O_WRONLY|O_CREAT|O_TRUNC, 0666)
open("ios::out|ios::trunc", O_WRONLY|O_CREAT|O_TRUNC, 0666)
open("ios::in|ios::out|ios::app", O_RDWR|O_APPEND|O_CREAT, 0666)
open("ios::in|ios::out|ios::ate", O_RDWR)
open("ios::in|ios::out|ios::trunc", O_RDWR|O_CREAT|O_TRUNC, 0666)

他得到了几乎所有有用的 fstream.open() 打开模式组合所对应的实际操作。可以看到,编译器版本的影响确实很大。
可以注意到,gcc 3.0.4 与 2.95.2 的测试结果相差不小。而且,最重要的是,有效的测试结果中,没有 in|out|app 这种组合。作者在最后发问,这种打开模式组合没有出现,是 bug 还是别的原因?可想而知,所谓「没有出现」,就是指这种打开模式组合总是会失败,相当于无法使用。就和我们遇到的情况一样。

再次的搜索,发现一年之后 gcc 那边出现了这样的帖子:
http://gcc.gnu.org/ml/gcc-prs/2003-04/msg00771.html
这是 gcc 开发组的人(应该是吧)发的,具体地说,是 paolo@gcc.gnu.org。帖子大概是针对一个 bug 报告的回应,内容就是说 in|out|app 这种打开模式组合根据 ISO 标准是非法的,无效的,因此这个 bug 报告可以 close 了。ISO 标准,应该就是 gcc 3.x 所遵循的 C++ 标准了。

至此,真相水落石出。我们也就安心改用 out|app 模式来写日志了。