跳到主要内容

32 篇博文 含有标签「码农札记」

查看所有标签

一个翻墙浏览器的想法

· 阅读需 7 分钟

最近,由于某些限制,所有基于 WordPress 的二级域名在国内被封锁,导致我的博客无法正常访问。这种情况让人颇为无奈。郁闷之余,我产生了一个想法:如果能够开发一个自带翻墙功能的浏览器,那将是一个非常实用的工具,尤其是对于需要访问自由网络的用户。

想法初探

技术上,实现一个支持翻墙功能的浏览器并非天方夜谭。我的初步思路是利用 P2P(点对点)技术 实现分布式网络代理。以下是大致的运行机制:

  1. 检测访问受阻的资源:
    当用户在国内的设备(比如设备 A)使用浏览器访问某个网站(例如 WordPress)时,如果发现连接受阻,浏览器会自动识别这种情况。

  2. 查找可用的外部节点:
    浏览器维护一个分布式网络,由全球各地的浏览器实例组成。此时,设备 A 会通过内置的 P2P 协议,向网络中的其他设备(例如设备 B)发起请求,询问是否能够访问目标网站。

  3. 中转与加密传输:
    如果设备 B(例如位于美国)能够访问目标网站,设备 A 会将请求内容加密后转发给 B。B 解密后访问目标网站,将获取的内容重新加密,并返回给设备 A。

  4. 用户隐私与安全保障:
    整个通信过程采用端到端加密,保证传输的数据安全,同时避免因流量分析而暴露用户的访问行为。

技术可行性与挑战

虽然这个想法在技术上可行,但其实现过程和推广应用中会面临以下几个主要问题:

1. 带宽与流量开销

让每个用户的设备充当中转节点会产生额外的流量负担,这对普通用户并不友好。特别是在带宽有限或流量计费昂贵的情况下,用户可能不愿意共享自己的网络资源。

解决方案:

  • 引入可选的贡献机制:允许用户手动启用或禁用中转功能,并对贡献更多带宽的用户提供激励,比如优先访问权或更高的连接质量。
  • 优化流量分配:通过智能路由算法,减少每个节点需要承担的中转负载。

2. 安全性与隐私保护

这种浏览器在中转数据时涉及多个用户设备,可能会带来数据泄露或滥用的风险。例如,某些节点可能窃取、篡改或记录用户数据。

解决方案:

  • 数据加密:确保所有传输内容使用强加密算法(如 AES 和 TLS),并在节点之间保持端到端加密。
  • 节点认证:使用公钥基础设施(PKI)对每个节点进行身份验证,防止恶意节点加入网络。

3. 技术限制与性能问题

P2P 技术虽然灵活,但会受到网络质量、NAT 穿透等因素的影响。如果中转节点不可用或性能较差,可能导致访问速度慢或连接失败。

解决方案:

  • 多节点选择:允许用户同时连接多个节点,从中挑选延迟最低、性能最佳的中转路径。
  • 混合模式:结合传统代理服务器(如 VPN、SOCKS5)作为备用方案,保障网络访问的稳定性。

4. 法律与合规性

开发和使用这种浏览器可能涉及复杂的法律问题,例如流量中转是否违反某些国家的规定,用户是否需要承担法律责任等。

解决方案:

  • 区域限制:根据不同国家的法律环境,提供特定版本的浏览器,或明确告知用户可能的法律风险。
  • 使用者自主性:将流量中转功能设计为用户可选功能,并提供透明的使用说明和安全协议。

总结

自带翻墙功能的浏览器在技术上是可行的,并且对于需要访问被限制内容的用户具有实际价值。然而,流量开销、隐私安全、性能优化和法律风险都是需要仔细权衡的问题。一个理想的解决方案需要在技术实现、用户体验和法律合规之间找到平衡点。

如果能够解决上述问题,这样的浏览器将不仅仅是一个工具,而是一个连接全球信息自由的桥梁。对于开发者来说,这既是一个技术挑战,也是一种社会责任。

欢迎大家对这个想法提出意见或补充,如果你对 P2P 技术或网络安全有研究,非常欢迎交流!

查看或添加留言

递归调用的层次限制

· 阅读需 5 分钟

今天调试一个大程序时遇到了一个崩溃的 Bug。这个程序并不是我写的,而崩溃时 Visual Studio 也没有直接给出调用链的信息,我只能自行分析问题所在。最后确定,程序是在一个递归调用的函数中崩溃的。

一看到递归函数,我就感到棘手。递归的调用深度可能非常大,成百上千层,根本没法逐层跟踪来确定崩溃点在哪里。不过后来,在同事的提醒下,我才意识到关键问题:程序崩溃时 Visual Studio 的错误信息是 “Stack Overflow”。这意味着问题很可能是递归调用层次过深,最终导致栈溢出。

关于递归的反思

递归是一种强大的编程工具,用起来非常简洁优雅。尤其是在解决一些像树遍历、分治算法、回溯问题等场景时,递归可以大幅简化代码的逻辑。

然而,经过这次调试,我深刻体会到递归的缺点:

  1. 不易调试:
    调试递归函数时,尤其是调用层次较深时,很难快速定位问题的发生位置。与此相比,循环的调试相对简单,因为可以轻松确定错误发生在某一特定的迭代中。

  2. 调用深度限制:
    每次递归调用会占用栈内存,调用层次过深时可能导致 栈溢出(Stack Overflow)。现代编程语言(如 C++、Java、Python)都对栈的大小有默认限制,具体取决于运行环境。

调试过程与解决方案

仔细分析后,我发现这个递归函数中存在一个逻辑错误,导致在某些特定情况下,递归无法到达预期的终止条件。这就是问题的根源——无限递归直接导致栈溢出。解决方法相对简单:

  1. 修正逻辑错误,确保递归函数能够在所有情况下正确达到终止条件。
  2. 在递归函数中添加一层保护机制,例如在函数入口检测当前调用深度,如果超过预期阈值,则强制退出,避免继续调用。

以下是一个改进的例子:

int safeRecursiveFunction(int n, int depth = 0, int maxDepth = 1000) {
if (depth > maxDepth) {
throw std::runtime_error("Recursion depth limit exceeded");
}
if (n == 0) {
return 1; // Base case
}
return safeRecursiveFunction(n - 1, depth + 1, maxDepth) * n;
}

通过引入 depthmaxDepth 参数,我们可以限制递归的层数,防止意外的栈溢出。

递归深度的实际限制

在大多数实际应用中,递归深度通常不会太大,因为合理设计的程序会在较浅的调用层次上满足终止条件。但如果需要处理非常深的递归调用(例如处理大规模数据结构或复杂递归算法),可以采取以下优化方法:

  1. 尾递归优化:
    一些编译器和语言(如 Python 不支持,但 Scheme、某些 C++ 编译器支持)可以对尾递归进行优化,将递归转化为迭代,从而避免栈溢出问题。

  2. 改用循环:
    如果递归逻辑过于复杂,且递归深度不易控制,可以尝试用循环代替递归。

  3. 手动管理栈:
    使用显式的数据结构(如栈)模拟递归调用。以下是一个简单的例子:

    void iterativeTraversal(TreeNode* root) {
    std::stack<TreeNode*> stack;
    stack.push(root);
    while (!stack.empty()) {
    TreeNode* node = stack.top();
    stack.pop();
    // 处理当前节点
    if (node->right) stack.push(node->right);
    if (node->left) stack.push(node->left);
    }
    }

这次调试经历让我更加认识到递归在带来代码简洁性的同时,也伴随着潜在的复杂性和风险。我们应该根据具体问题场景权衡递归与其他实现方式的优劣,确保程序在高效运行的同时具备良好的可维护性和可靠性。

查看或添加留言

Silverlight程序中显示帧数的方法

· 阅读需 5 分钟

接到一个需求:要在 Silverlight 程序的界面上放置一些控制选项,可以开关 Silverlight 的 EnableFrameRateCounterEnableRedrawRegions 等和显示效率相关的一些设置项。我开始在网上查了一下,发现这几个与显示性能相关的设置主要用于承载 Silverlight 控件的 HTML 页面中,作为初始化参数进行设置。

这些属性(EnableFrameRateCounterEnableRedrawRegionsEnableGPUAcceleration)在 Silverlight 程序启动后仍然可以通过 JavaScript 代码在浏览器端进行修改,但并非所有修改都能立即生效。正如你所说,EnableGPUAcceleration 属性必须在 HTML 文件中设置为 trueEnableCacheVisualizationEnableFrameRateCounter 才能正常工作。这是因为这两个属性依赖于硬件加速,如果硬件加速没有启用,它们也就失去了意义。微软提供这几个属性的主要目的是为了帮助开发者调试和优化使用显卡加速的动画和图形效果。

EnableFrameRateCounter 被设置为 true 时,Silverlight 界面左上角会显示一些与 GPU 相关的数据,其中包括动画的刷新帧数 (FPS)。这个内置的帧数计数器对于快速查看性能很有用。然而,Silverlight 并没有直接提供 API 可以在程序代码中读取这个帧数数值。

为了在程序中获取帧数,通常需要使用 CompositionTarget.Rendering 事件。Silverlight 在每一帧渲染完成后都会触发这个事件。通过对该事件进行计数,并计算单位时间内事件发生的次数,就可以近似地得到每秒帧数。

但正如你发现的,通过 CompositionTarget.Rendering 事件计算出来的帧数,与 EnableFrameRateCounter 显示的帧数通常是不同的。这是因为两者统计帧数的机制有所差异:

  • EnableFrameRateCounter 显示的是 Silverlight 实际 渲染 的帧数,它反映了 GPU 的实际工作情况。如果 Silverlight 认为界面没有变化,不需要重绘,就不会触发渲染,显示的帧数就会较低,甚至为 0。例如,如果界面上只有一个静态图像,没有动画或用户交互,帧数就会很低。
  • CompositionTarget.Rendering 事件的触发次数与用户设置的 MaxFrameRate 和系统的繁忙程度密切相关。即使界面没有变化,只要时间到达了 MaxFrameRate 设定的间隔,CompositionTarget.Rendering 事件仍然会被触发。因此,通过该事件计算出的帧数,更接近于 Silverlight 尝试 渲染 的最大帧数,而不是实际 完成 的渲染帧数。

此外,还有一些细节需要补充:

  • MaxFrameRate 属性用于限制 Silverlight 应用程序的最大帧速率。默认情况下,Silverlight 的最大帧速率是 60fps。可以通过设置 MaxFrameRate 来降低帧速率,以节省 CPU 和 GPU 资源。
  • 使用 CompositionTarget.Rendering 计算帧数时,需要注意性能问题。频繁的事件处理可能会对程序性能产生一定的影响。因此,通常需要使用一个定时器来周期性地计算和更新帧数,而不是在每次事件触发时都进行计算。
  • 除了 EnableFrameRateCounterCompositionTarget.Rendering,还可以使用一些性能分析工具来更全面地了解 Silverlight 应用程序的性能,例如 Visual Studio 的性能分析器。

总而言之,EnableFrameRateCounter 提供了一个方便的快速查看帧数的方法,而 CompositionTarget.Rendering 则提供了一种在程序中计算帧数的途径。但需要理解它们之间的差异,并根据实际需求选择合适的方法。

查看或添加留言

Scrum

· 阅读需 8 分钟

我居然没能在网上找到这个词的中文翻译(也许可以称为迭代开发?)。Scrum 是一种敏捷开发框架,并非简单的“迭代开发”就能完全概括。虽然迭代是 Scrum 的核心组成部分,但 Scrum 还包含许多其他重要的概念和实践。直接使用英文“Scrum”是比较常见的做法。

Scrum 的核心思想是将大型、复杂的项目分解成小的、可管理的迭代周期,称为“Sprint”。每个 Sprint 的长度通常为 1 到 4 周(最常见的是 2 周)。在每个 Sprint 中,团队完成一部分可交付的产品增量。你用 LabVIEW 举例,如果采用 Scrum,并非一定要每月发布一个版本,而是每个 Sprint 结束时交付一个可用的增量。这个增量可能是一个新功能、一个 bug 修复,或者对现有功能的改进。重要的是,每个 Sprint 结束时,产品都应该有所进步,并可以进行演示和评估。因此,一年 12 个 Sprint,并不意味着一定有 12 个对外发布的大版本,而是 12 次内部的迭代交付和评审。

Scrum 的流行,一定程度上受到了 Google 等公司的影响,但并非完全是“示范效应”。更重要的是,Scrum 能够更好地应对快速变化的需求和市场环境,提高软件开发的效率和质量。Google 的“永远的 Beta 版”策略,更接近于持续交付和精益创业的理念,与 Scrum 有相似之处,但并非完全等同。持续交付强调更频繁的发布,甚至每天多次发布,而 Scrum 则更侧重于 Sprint 内的迭代和增量交付。

你提到“软件的很多新功能都是程序员或者经理拍脑袋想出来的,它们或许并不符合用户的需求”,这正是 Scrum 想要解决的问题之一。Scrum 强调以用户为中心,通过频繁的沟通和反馈,确保开发团队始终在构建用户真正需要的产品。尽早将产品展示给用户,并根据用户反馈进行调整,是 Scrum 的核心原则之一,这有助于减少浪费和提高用户满意度。

Scrum 流程并非适用于所有领域,你的盖楼例子很好地说明了这一点。盖楼的工序之间有严格的依赖关系,无法像软件开发那样进行灵活的迭代。将挖坑、地基、墙体等工序强行拆分成按月交付的“增量”,显然是不切实际的。Scrum 更适合于需求变化频繁、复杂性高、需要快速反馈和调整的项目,例如软件开发、网站开发、市场营销等。而对于一些流程固定、工序依赖性强的项目,例如建筑工程、大规模生产制造等,传统的瀑布式开发方法可能更为合适。

实际上,并非所有采用了 Scrum 的公司都获得了成功,例如,引入 Scrum 之后的诺基亚。Google 的成功和诺基亚的失败或许表明,开发流程并非决定企业成败的唯一因素,甚至不是主要因素。企业的成功与否受到市场、战略、产品、管理等多种因素的综合影响。也有可能,Scrum 并非万能药,它只在某些领域才能发挥出最佳效果。任何方法论都有其适用范围和局限性。

回到我熟悉的 LabVIEW。LabVIEW 的主要应用领域仍然是工业测控。这个领域的用户与互联网用户的一个明显差别在于,工业领域的客户通常更加谨慎,对稳定性的要求远高于对新功能和快速迭代的追求。他们更倾向于经过充分验证和测试的成熟技术。LabVIEW 每年升级一次,居然还有很多用户抱怨说升级太快了。与之对比,某个拼音输入法的用户们天天在论坛上抱怨:“都几个星期了,怎么还不更新?”如果让 LabVIEW 的用户每个月更新一次,他们恐怕难以接受。我估计 LabVIEW 在这一点上是无法完全照搬 Google 的模式的。工业领域的用户需要的是长期稳定可靠的解决方案,频繁更新反而可能带来不必要的风险和兼容性问题。

对于不能频繁从外部客户那里收集反馈的软件来说,如果一定要采用 Scrum 流程,那就只能更多地依赖内部反馈,例如从产品经理、测试人员、甚至是高层管理那里获取反馈。每个 Sprint 交付一个内部版本,然后给他们评审,再基于反馈进行下一个 Sprint 的开发。但我个人认为,敏捷开发的核心精髓在于最终用户的快速反馈,这使得软件可以紧跟用户需求的变化。内部人员,无论是谁,他们的需求都必然与最终用户有所偏差,都无法完全代表最终用户。因此,仅仅依靠内部反馈的“敏捷”,效果会大打折扣。至于 Scrum 流程中的其他实践,例如每日站立会议、用户故事、燃尽图等,都只是辅助手段,形式大于内容。没有最终用户的深度参与,新的开发流程可能只是换汤不换药,并不能真正发挥敏捷的优势。这种情况下,可能需要对 Scrum 进行一定的调整和裁剪,使其更适应内部驱动的开发模式。例如,可以更侧重于内部的迭代评审和测试,以确保产品质量和内部需求的满足。

查看或添加留言

计算机病毒防治的原理和未来发展

· 阅读需 21 分钟

尽管我的专业并不涉及计算机病毒,而且公司的计算机安全措施也相当到位,导致我个人的计算机已经七八年没有中过病毒了。然而,前段时间放假回家时,帮姑姑修理电脑,发现她的电脑被病毒侵扰严重。安装杀毒软件并清理系统后,总共查出二十多个流氓软件和五十多个病毒和木马。这让我意识到,病毒问题依然猖獗,没有杀毒软件的保护,电脑安全是得不到保障的。

这次经历激发了我对病毒的兴趣,于是我花了几天时间,阅读了一些关于病毒与杀毒技术的文章。接下来,我将总结这几年来杀毒技术的发展,并分享我对未来发展的看法。在查阅相关文章时,我看到一位网友将杀毒软件与病毒比作城市中的警察和小偷,我认为这个比喻非常恰当,于是在此引用:

科普版寓言版
电脑中除了我们所需的软件,有时还会混进一些专门搞破坏的程序——计算机病毒。计算机普及后,许多用户并不是计算机专业人士,他们不了解程序运行的原理,也分不清哪些是正常程序,哪些是病毒程序。因此,我们需要使用专门的杀毒软件来清除这些危害系统安全的病毒程序。并不是城里每个人都是好人,有些人专门混进来搞破坏。城堡的主人不一定认识城中每个居民,他根本不清楚哪些是良民,哪些是坏蛋。所以,他决定雇佣一些专职警察来抓捕城中坏人,维护城堡的安全。
杀毒软件识别病毒的经典方式是特征码识别,通过病毒文件特有的一些属性、内容等找到它。比如,文件名就是一个特征,某些病毒的文件名是固定的,一看到这个文件,就知道它是病毒。作为警察,最简单的找到坏蛋的方法是:拿到一份公安部颁发的通缉令,上面列有所有已知坏蛋的姓名。警察检查城中每一个居民,如果他出现在通缉令名单中,就将其逮捕。
病毒可以轻易地更改自己的名字,以避开杀毒软件的检查。所以,大多数杀毒软件不用文件名做特征值,比文件名更不容易改变的是病毒程序中关键的一段代码。比如,病毒专用于搞破坏的那段代码,正常程序不应该有这种代码。而且,这段代码相对稳定,不论病毒其它部分怎么改动,它要完成使命,就必须保留这段代码。然而,名字是最容易改的,坏人造个假身份证非常简单。所以,优秀警察在抓坏蛋时,不是根据名字来判断。他们的通缉令上列出的是小偷的一些外观特征。比如,某小偷手持作案工具"螺丝刀",正常人不会时刻拿着螺丝刀。于是,警察把凡是手里拿着螺丝刀的居民都当作小偷抓起来。
病毒为了进一步躲避杀毒软件,需要隐藏自己的特征码。常用的方式是把自己关键代码部分进行加密,不运行时,杀毒软件检测不到相应特征码。病毒运行后,再把其破坏性代码解码并运行。如果病毒只采用一种加密方式,加密后的代码很快就会成为杀毒软件追查的新特征码。所以狡猾的病毒会采用随机加密方式,衍生出无数变种。小偷也不笨,他们会改变特征来对付警察。平时不犯案时,把螺丝刀揣兜里,作案时再掏出来。此外,再时不时换几件外衣,警察就更难认出来了。
对付变种病毒,有些杀毒软件只是简单地把变异后的新特征码添加到病毒特征码库中。这种方法效率较低,有的杀毒软件病毒库虽庞大,杀毒能力却一般。能力更强的杀毒软件可以剥去常见伪装,找出病毒。具有启发式查找能力的杀毒软件还会为病毒创造一个虚拟运行环境,让可疑程序在其中运行,直到病毒露出马脚。对付化妆的小偷,笨警察通过增加罪犯特征信息来稽查,比如在通缉犯特征列表里增加:穿黑大衣,左脸颊有痣的;穿花格上衣,绿短裤,凉拖鞋的;穿白衣服,叼烟,手有疤的,等等。聪明警察则把所有被检查者的衣服脱掉,搜到螺丝刀的就是罪犯。聪明警察还知道引诱罪犯露出原形,比如把保险箱放在某人面前,那人见到保险箱立刻掏出螺丝刀,就是罪犯。
用于盗版的注册机类软件,通常需要挂到别的程序上,或从别的程序进程中读取数据。这些行为跟病毒的行为非常类似,所以注册机程序经常被杀毒软件误认为是病毒。警察有时会抓错人,比如看到掏出螺丝刀的不一定都是小偷,也可能是开锁匠。
早期,杀毒软件的主要工作是查杀磁盘上的病毒。扫描磁盘上每个文件,检查其中是否有与病毒库中特征码相同的代码。若找到,则清除病毒。现在的电脑硬盘容量较大,一个电脑可能包含几百万个文件,扫描所有文件可能耗时数小时,占用大量计算资源。最初,警察稽查坏蛋的方法是挨家挨户搜查。当年城堡规模较小,全搜查一遍花不了多少时间。但现在城市动辄几百万人口,挨家挨户搜查效率极低。
病毒只能通过网络、文件拷贝等方式进入一台计算机,只要在文件进出本机时进行监控,就能杜绝病毒传播。同时,硬盘上的病毒如果不被运行或转移,也不具备破坏性。所以,保证系统安全并不需要检查硬盘上的每个文件,只需监管住主要几个地方即可。 目前,口碑较好的几款杀毒软件,有些以查杀效果见长,有些则以监控效果闻名。如果监控得当,让病毒没有机会进入电脑或运行,是否检查硬盘就无关紧要了。实际上,警察并不需要经常性普查每个人,也能保证城堡安全。他只要守住关键区域,比如在城门、城中心广场等场所检查进出人员;在银行检查资金流动户头等。如果坏蛋老老实实呆在家里,不会对治安产生威胁,只需在他干坏事前抓住他即可。
科普版寓言版
以前,病毒制造者往往只是为了炫耀自己的编程水平,他们通常不会从中获得经济利益。因此,新病毒出现的速度并不快。然而,近年来,病毒产业发现了新的利润增长点:窃取计算机主人信息、资料、密码等并出售。利益驱动下,病毒制造业迅速发展,新病毒的数量每年翻几番。最初,混进城里的坏蛋只是为了搞破坏。这种损人不利己的事只有少数人会做,所以警察的任务并不重。但后来,坏蛋们意识到可以从城中的银行偷钱。于是,各种小偷蜂拥而至。
新病毒数量的激增导致了杀毒软件病毒特征库的膨胀。庞大的特征库占用了大量资源,不仅需要更多的存储空间,还延长了比对过程的计算时间。面对各式各样的小贼——拿丝刀的、拿扳手的、拿锤子的、拿自行车条的,采用传统按通缉令比对的方式,即便是聪明的警察也快要招架不住了。通缉令已经长得令人眼花缭乱。
解决这一问题的办法之一是云存储和云计算,这正是最近一些杀毒软件公司热炒的概念。杀毒公司拥有比客户更强大的计算和存储资源,具备云计算功能的杀毒软件只会把最常见的部分病毒特征码放到被保护的计算机上。一旦发现可疑文件,无法确认是否含有病毒,则将文件交给杀毒公司。公司服务器拥有庞大的病毒特征库,可以对可疑文件进行全面检查,确认是否包含病毒后,再将结果返回给客户端。为减轻警察的负担,公安部决定只把近期破坏性最大的通缉犯列表发给各地警察。警察若发现不在列表中的可疑分子,可以直接交给公安部。公安部有完整的通缉犯名单,并且可以调动更多的警察,以最快速度查明可疑分子的身份。
实际上,病毒还有比特征码更为本质、稳定的特征,那就是行为。病毒千变万化,但其行为特征只有几条,比如自我繁殖和传播、试图取得计算机控制权、试图查看或改变其它可执行文件、试图访问某些数据等。外部特征并不是罪犯最本质的特征。之所以称其为罪犯,是因为他有犯罪行为。
监视病毒特征码必须在病毒被发现并提取特征码之后,才能进行监视。这时,病毒可能已经造成损失。更何况,现在新病毒的增长速度远超杀毒软件公司更新病毒特征库的速度,反应速度只会越来越慢。以外观特征来识别罪犯,必须等到该人被确认为罪犯后,公安部门整理出其外观特征,才能提供给其他警察比对。对于没有案底的犯人来说,警察无法识别。一旦进了通缉令,说明此人已经给社会带来了危害。
监视病毒行为可以彻底解决提取病毒特征码带来的滞后问题。监视病毒行为的软件可以在不知道病毒特征码的情况下,根据软件行为判定其是否为病毒。有没有更有效的制止犯罪的方法?能否在罪犯造成损失前将其制服?答案是肯定的,通过监视犯罪行为可以做到。
监视程序行为的软件早已有之。最简单的方法是控制住系统中所有关键位置,比如某些文件夹、注册表键值等。凡是试图改变这些部位的操作都会被暂时阻拦,然后询问电脑操作者是否允许这些操作。当前,流行的计算机辅助软件都有此功能,安装后会经常弹出提示窗口,询问是否允许某某操作。然而,计算机使用者不一定知道这些操作意味着什么,是否安全,这类监视大多情况下只会增添麻烦,并无实际意义。最初级的监视方式是对城中关键区域进行监控,比如银行、交通要道等。凡是试图进入银行的人都会被拦住,询问主管是否允许其进入。可惜主管并非全知全能,他也不知道进入银行的人是为了正常业务,还是企图抢劫。所以,这种监视方法除了增加主管负担外,并未加强城堡安全。
近年来,一些行为监控软件添加了智能判断功能,可以忽略安全操作,只显示病毒行为。如果用户选择清除病毒,还可以将病毒所作所为复原。这些软件被称为主动防御型防毒软件。好在,最近出现了一批具备智能判断能力的警察。他们不阻拦人们进入银行,而是只在有人行窃时才将其逮捕,并追回赃款。
主动防御型防毒软件与传统的特征码查杀病毒的软件相比,是一项新兴技术,许多方面尚待完善,未必在所有情况下都比传统杀毒软件更好。然而,主动防御型防毒软件可以彻底脱离病毒特征码工作,及时阻隔新病毒,适应当前新病毒数量激增的形势,因此可以肯定地说,它将是未来防杀毒软件的发展方向。实际上,许多传统杀毒软件也已经开始添加主动防御功能。新警察虽然技术水平更高,但缺乏经验,有时表现不如老警察。不过,具有智能识别犯罪行为的警察可以彻底摆脱通缉令,独立工作,并在初次犯罪时就将其拦截。这必然是今后所有警察的培训目标。
科普版寓言版
计算机的发明是为了让人们从简单重复的工作中解脱出来。然而,关于防病毒这一工作,目前仍无法完全由计算机自动完成。尽管现有的优秀杀毒软件可以在没有终端用户参与的情况下独立维护计算机安全,但它们仍需要杀毒软件公司不断进行维护。不论是升级病毒特征库,还是制定新的防病毒行为规则,都离不开专业人员的参与。随着新病毒行为的变化速度加快,杀毒软件编写人员将越来越难以应对。因此,将来有一天,计算机应该能够独立抵御病毒的入侵。考虑一个有机生命体,比如一只小兔子,它不需要外界(例如上帝)的帮助,就能独自抵御疾病。
未来,计算机肯定需要建立一套类似生物体内免疫系统的机制来抵御病毒。免疫系统会持续监视计算机的健康状况,一旦出现异常,如可使用资源减少或磁盘被垃圾文件占据,则将近期新进入的程序视为引起系统异常的罪魁祸首并加以清除。如果清除这些文件后系统恢复健康,则再也不允许这类文件进入系统。当某种病菌第一次进入小兔子的身体时,小兔子可能不会立即对其作出反应。但一旦病菌繁殖起来,小兔子的免疫系统会意识到这是个危险的外来物,进而启动免疫功能将其消灭。小兔子的免疫系统会记住这些病菌,若同一种病菌再次入侵,小兔子的免疫系统会直接将其消灭。
某些病毒一旦发作,可能很难彻底清除。但在电脑被病毒破坏至崩溃之前,至少可以将病毒信息发送给其他电脑。这样,其他电脑可以在病毒发作前识别并清理它。有些病菌非常厉害,单个生命体无法抵御其入侵。然而,生命体组成的物种整体不会被病菌消灭。这是因为个体在病菌进攻下死去,但会将病菌入侵的信息传递给其他个体。人类在这方面尤为典型:某些人染病死去,其他人可以通过研究死者获得抵御该病菌的疫苗,从而保护其他个体抵御疾病。
查看或添加留言

几个常用博客服务网站的比较

· 阅读需 6 分钟

在博客兴起的早期,我开通的第一个账号是在 Blogspot。这个平台如今已成为 Google 网络服务的一部分,是最早一批博客服务网站之一,功能强大且用户体验良好。在国外,它一度是博客领域的主导者。然而遗憾的是,这个平台在中国还未曾流行开来,就被墙了。

博客的出现,让我们这些普通人第一次拥有了一个便捷的平台,可以将自己的思想自由地分享给大众。在思想表达更加自由的同时,难免有一些言论会触及某些敏感神经。对于此类“挑战”,我们政府的应对方案通常就是封锁。这也使得 Web 2.0 技术在中国的发展显得格外曲折。

等到我开始认真写博客的时候,正赶上微软公司开拓博客领域。MSN Space 推出时,博客在中国已经积累了一定的用户,但当时的主力群体仅局限于技术人员和学生。MSN Space 凭借与 MSN Messenger 的无缝集成,成功地将博客推广到普通用户的手中。在短时间内,MSN Space 一跃成为全球最大的博客服务平台。

我之所以选择 MSN Space,最初的原因就是 Messenger 上那颗闪烁的小星星。它可以提醒我的好友查看我的博客更新,同时也让我快速了解谁发布了新文章。这种互动性让博客成了与朋友保持联系的好工具。

然而,时至今日,MSN Space 已改名为 Windows Live Space,我的使用体验却每况愈下。作为一个主要用博客记录技术文章、个人想法和生活点滴的用户,我需要的是一个简洁、高效、反应迅速的平台。然而,Windows Live Space 显然走上了一条相反的道路。它加载速度慢得让人抓狂,打开页面时浏览器经常“卡死”。那些看似花哨的附加功能,对我的需求并无帮助,反而成了累赘。此外,我希望拥有的一些基础的统计功能,例如分析访问数据的规律等,Windows Live Space 却并未提供,甚至连主流的 Google Analytics 都不支持。

对我来说,博客是一个简单记录与分享的工具,完全不需要那些华而不实的功能。面对如此糟糕的体验,我也不得不开始思考,是否需要寻找一个更适合自己的平台了。在 MSN Space 推出之后,博客在中国迅速流行起来。如今,中国的几大门户网站几乎都提供博客服务,我也大多都尝试过了。让我印象最深的还是百度空间。坦白说,我对百度一向没有什么好感,尤其是它在与 Google 竞争中采取的一些不光彩手段,让我颇为厌恶。然而,不得不承认,百度在某些产品的设计上确实有其过人之处,比如百度贴吧和百度空间。

百度空间是我用过的博客平台中最简洁高效、速度最快的一个,并且它提供了我所需要的大多数功能。例如,流量统计、内容管理、访问反馈等功能都设计得相当实用。这让我不止一次犹豫,要不要把我的博客迁移到百度空间。

然而,迁移博客并不是件容易的事。毕竟,我在 Windows Live Space 上经营了很久,积累了大量的文章和外部链接。要修改所有链接指向,几乎是不可能完成的任务。正所谓“上贼船容易,下贼船难”,我只能无奈地继续使用 Windows Live Space。

随着 Web 2.0 技术的不断发展,当初吸引我到 MSN Space 的那个小黄星星早已不再重要。很长一段时间,我都没有留意哪个好友的 MSN Messenger 旁边闪烁着星星了。如今,我更多地依赖 RSS 订阅来追踪博客和新闻的更新。

查看或添加留言

整合 wiki, blog 和 forum

· 阅读需 4 分钟

现在,Wiki、Blog 和论坛都已经成为我工作中不可缺少的工具了。它们各有优缺点:

在 Wiki 上,任何人都可以对文章进行修改。所有修改历史都会被记录下来,用户可以方便地查看每个文档的历史版本。因此,Wiki 非常适合用于发布组织规范、制度等文档。它的最大缺点是无法对单篇文章发表评论。如果强行在原文下方添加评论,会破坏原文的版本更新记录。这确实是个让人头疼的问题,因为很多时候我们希望在阅读文档的同时进行讨论。

Blog 解决了发表评论的问题,读者可以对文章发表评论并提供反馈。这使得作者可以更好地了解读者的想法,并及时进行调整。然而,它也丧失了 Wiki 的优点。通常,一个 Blog 站点只有一个人发布文章,作者可以修改自己发布的文章,但历史记录无法保存下来。这意味着我们很难追溯文章的修改过程,也无法方便地回溯到之前的版本。所以 Blog 更适合个人使用,用于发表看法和收集反馈。当然,也可以用于为某个项目收集反馈,但协作性就稍差一些。

论坛和 Blog 的区别主要在于分类方法。Blog 是按作者分类的,而论坛则是按照文章内容分类的。当你对某一个话题感兴趣,想查看其他人对它的看法时,论坛的形式更为适合。它提供了一个集中的讨论场所,让大家可以围绕共同的主题进行交流。现在,很多论坛和 Blog 已经结合在一起了。网站的数据只有一份,但可以根据作者分类查看文章,看上去像个 Blog;按照文章内容分类时,又像是论坛。这其实已经是一种简单的整合尝试了。

我想一定有人已经考虑过将这三者整合在一起的想法,但目前还没有见到成熟的产品。这样的网站应当具备以下功能:进入网站后,读者可以选择按照文章内容查看,还是按照组织或个人作者查看。其中组织是由若干个成员组成的,发表在组织入口下的文章,每个组织成员都可以进行修改,而读者不能修改非自己组织的文章。读者可以对网站的所有文章发表评论,但除评论发布者外,其他人不能对评论进行修改。网站的所有文章的修改记录都应被保存下来,读者可以查看文章的旧版本内容。这样,我们就既拥有了 Wiki 的版本控制和协作编辑能力,又拥有了 Blog 和论坛的评论和主题讨论功能,岂不美哉?

查看或添加留言

VC中的字节对齐

· 阅读需 24 分钟

当我们在C语言中定义一个结构体时,其大小是否等于所有字段大小的总和?编译器又是如何在内存中安排这些字段的?ANSI C标准对结构体的内存布局有哪些规定?而我们的程序是否能够依赖这些布局?这些问题可能困扰着不少开发者,因此本文将试图揭开其中的奥秘。

结构体的内存布局

字段顺序与内存地址

首先,有一点是确定的:ANSI C保证结构体中各字段在内存中的位置与其声明顺序一致,且第一个字段的首地址与整个结构体实例的首地址相同。例如,以下代码验证了这一点:

struct vector { 
int x, y, z;
} s;

int *p, *q, *r;
struct vector *ps;

p = &s.x;
q = &s.y;
r = &s.z;
ps = &s;

assert(p < q); // x 的地址小于 y
assert(p < r); // x 的地址小于 z
assert(q < r); // y 的地址小于 z
assert((int*)ps == p); // 结构体地址等于第一个字段地址

在这个例子中,断言始终成立。然而,有读者可能会好奇:ANSI C是否保证相邻字段在内存中也是紧邻的?
答案是否定的,ANSI C标准并未对此做出保证。因此,程序中不应假定字段在内存中是连续的。

尽管如此,我们仍然可以通过了解编译器和平台的实现,构建一幅更清晰的内存布局图。要做到这一点,需要先了解一个关键概念——内存对齐

内存对齐的概念

在许多计算机系统中,基本类型数据在内存中的存放位置受到限制。具体来说,某些数据类型的首地址必须是某个特定值 ( k )(如4或8)的倍数。这种限制称为内存对齐,而 ( k ) 被称为数据类型的对齐模数(alignment modulus)。

如果一种数据类型 ( S ) 的对齐模数是另一种数据类型 ( T ) 的整数倍,则称 ( S ) 的对齐要求比 ( T ) 更严格,反之 ( T ) 则更宽松。

内存对齐的主要目的是:

  1. 简化处理器与内存之间的传输设计。
  2. 提高数据访问效率。例如,一个8字节对齐的 double 类型变量只需要一次内存操作即可读取;而未对齐的数据可能需要两次内存操作,因为它可能跨越两个对齐块。

某些处理器在遇到未对齐数据时会产生错误,但Intel的IA32架构处理器可以正确处理未对齐数据。不过,Intel建议开发者尽量遵循对齐要求以提升性能。

不同平台的对齐规则

在Win32平台上,微软C编译器(cl.exe for 80x86)默认使用以下规则:任何基本数据类型的对齐模数等于其大小(即 sizeof(T))。例如:

  • double 类型(占8字节)的地址必须是8的倍数。
  • int 类型(占4字节)的地址必须是4的倍数。
  • char 类型(占1字节)则可以存储在任何地址。

这种规则确保了基本数据类型的访问效率。

在Linux平台上,GCC的对齐规则稍有不同(以下内容根据文档整理,未验证,如有错误请指正):

  • 任何2字节大小的数据类型(如 short)的对齐模数为2。
  • 超过2字节大小的数据类型(如 longdouble)的对齐模数为4。

这些差异表明,结构体的内存布局并不完全统一,而是依赖于编译器和平台。了解这些规则后,我们可以更好地优化代码,同时避免潜在的跨平台问题。

结构体布局的实例分析

结构体的对齐与填充

现在,我们回到结构体的讨论中。ANSI C规定,一个结构体的大小等于其所有字段大小与填充字节的总和。那么,什么是填充字节?填充字节是编译器为了满足字段的对齐要求而在字段之间或结构体尾部额外分配的空间。

此外,结构体本身也有对齐要求。根据ANSI C标准,结构体的对齐模数必须是其所有字段中对齐模数最严格的那个。虽然标准允许编译器使用更严格的对齐方式,但这并非强制要求。例如,微软的VC7.1编译器默认让结构体的对齐要求与最严格字段的对齐要求相同。

以下示例展示了填充字节如何影响结构体的布局:

简单结构体的布局

typedef struct ms1 {  
char a;
int b;
} MS1;

假设 MS1 的内存布局如下:

|   a   |       b       |
Bytes: 1 4

MS1 中,对齐模数最严格的字段是 bint),其对齐模数为4。因此,根据对齐规则,MS1 的首地址必须是4的倍数。但在这种布局下,b 的地址并未满足4字节对齐要求。

为了满足要求,编译器会在 ab 之间插入填充字节(padding):

|   a   | padding |       b       |
Bytes: 1 3 4

这种布局使得 b 的地址对齐到4的倍数,同时满足了结构体整体对齐的要求。最终,sizeof(MS1) 为8字节,b 字段相对于结构体首地址的偏移量为4。

复杂结构体的布局

现在,我们交换字段的顺序:

typedef struct ms2 {  
int a;
char b;
} MS2;

如果按照直觉,MS2 的内存布局可能是:

|       a       |   b   |
Bytes: 4 1

这种布局看似合理,但忽略了一个重要问题——数组的连续性

结构体数组的对齐

ANSI C标准规定,任何类型(包括结构体)的数组,所有元素必须是连续存储的。因此,一个 MS2 数组的布局应满足以下条件:

|<- array[0] ->|<- array[1] ->|<- array[2] ->| ...

如果 MS2 的大小是5字节,那么每个结构体元素之间会出现空隙,违反了数组的连续性规则。

为了避免这种情况,编译器会在 b 后面添加填充字节,使结构体的大小对齐到 a 的对齐模数(即4的倍数)。最终,MS2 的实际内存布局如下:

|       a       |   b   | padding |
Bytes: 4 1 3

因此,sizeof(MS2) 为8字节,确保数组元素可以连续存储。

当数组的首地址满足对齐要求时,我们需要确保数组中的每个元素都符合结构体字段的对齐规则。如果某个字段无法在数组中连续对齐,则需要通过增加填充字节解决这一问题。

例如,对于以下结构体:

typedef struct ms2 {
int a;
char b;
} MS2;

通过调整布局,可以让数组中每个元素的所有字段都满足对齐要求:

|       a       |   b   | padding |
Bytes: 4 1 3

这种布局确保了数组中的每个元素(如 array[0].aarray[1].a)都对齐到 4 字节的边界,同时满足字段的对齐规则。sizeof(MS2) 为 8,a 的偏移量为 0,b 的偏移量为 4。

更复杂的结构体

接下来,分析一个稍微复杂的结构体类型:

typedef struct ms3 {
char a;
short b;
double c;
} MS3;

通过以下步骤,我们可以推导出正确的内存布局:

  1. a 是一个 char 类型,占用 1 字节。
  2. b 是一个 short 类型,占用 2 字节,但要求从偶数地址开始,因此在 a 后面填充 1 个字节。
  3. c 是一个 double 类型,占用 8 字节,要求从 8 的倍数地址开始。在 b 后面需要填充 4 个字节。

最终的内存布局如下:

|   a   | padding |   b   | padding |       c       |
Bytes: 1 1 2 4 8

根据该布局:

  • sizeof(MS3) 为 16。
  • 字段偏移:a 为 0,b 为 2,c 为 8。

嵌套结构体

现在,考虑一个包含其他结构体类型的结构体:

typedef struct ms4 {
char a;
MS3 b;
} MS4;
  1. a 是一个 char 类型,占用 1 字节。
  2. b 是一个 MS3 类型,其对齐模数等于 double 的对齐模数(8 字节)。因此,在 a 后面需要填充 7 个字节以满足对齐要求。

布局如下:

|   a   | padding |               b               |
Bytes: 1 7 16

因此:

  • sizeof(MS4) 为 24。
  • 字段偏移:a 为 0,b 为 8。

对齐规则的自定义与平台依赖性

改变对齐规则

在实际开发中,可以通过编译器选项来更改对齐规则。例如,在 Visual C++ 中,使用 /Zp 选项可以指定最大对齐模数 (n 可以是 1、2、4、8 或 16)。这些对齐规则的效果如下:

  • 小于或等于 n 字节的基本数据类型,其对齐方式保持不变。
  • 大于 n 字节的类型,其对齐模数将受到限制,不超过 n

例如,默认情况下,VC 的对齐方式相当于 /Zp8。然而,编译器文档(MSDN)明确指出,在某些平台(如 MIPS 和 Alpha)上使用 /Zp1/Zp2 可能导致问题,而在 16 位平台上指定 /Zp4/Zp8 也可能出现问题。这些警告提醒我们,对齐规则的影响不仅仅是内存布局,还可能影响程序的可移植性和性能。

程序的可移植性

由于结构体的内存布局依赖于 CPU 架构、操作系统、编译器以及编译时的对齐选项,开发人员在跨平台开发时必须小心处理:

  1. 避免依赖特定的内存布局:除非绝对必要,否则不应依赖编译器生成的特定对齐方式。例如,当设计开放源码库时,不同用户可能会在不同的平台或编译器下编译代码,这可能导致潜在的兼容性问题。
  2. 模块间对齐一致性:如果程序的不同模块是用不同的对齐选项编译的,可能导致非常微妙且难以调试的错误。在调试复杂行为时,应检查所有模块的编译选项是否一致。

实践与总结

实践与思考题

以下是一些结构体的定义,请分析它们在你的开发环境中的内存布局,并尝试通过调整字段顺序来优化内存使用:

A. struct P1 { int a; char b; int c; char d; };
B. struct P2 { int a; char b; char c; int d; };
C. struct P3 { short a[3]; char b[3]; };
D. struct P4 { short a[3]; char *b[3]; };
E. struct P5 { struct P2 *a; char b; struct P1 arr[2]; };

提示:优化字段顺序时,需要遵循以下原则:

  • 将对齐要求更高的字段放在前面,减少填充字节。
  • 尽量避免因数组或嵌套结构体导致的对齐浪费。

sizeof 的案例分析

考虑以下结构体:

struct MyStruct {
double dda1;
char dda;
int type;
};

从表面看,sizeof(MyStruct) 可能被误以为是 sizeof(double) + sizeof(char) + sizeof(int) = 13。但在实际测试中,结果是 16。这是因为编译器为提高 CPU 访问效率,对成员变量的存储进行了 对齐处理

  1. double 类型占用 8 字节,偏移量必须是 8 的倍数。
  2. char 类型占用 1 字节,但为了满足后续 int 类型的对齐需求,需在后面填充 3 字节。
  3. int 类型占用 4 字节,偏移量必须是 4 的倍数。

因此,实际布局如下:

|   dda1   |   dda   | padding |  type   |
Bytes: 8 1 3 4

在结构体中,每个成员变量的对齐规则根据其数据类型的大小决定,具体如下:

  • char:偏移量必须是 sizeof(char)(1 字节)的倍数。
  • short:偏移量必须是 sizeof(short)(2 字节)的倍数。
  • intfloat:偏移量必须是 sizeof(int)sizeof(float)(4 字节)的倍数。
  • double:偏移量必须是 sizeof(double)(8 字节)的倍数。

此外,为了确保结构体的总大小是其最大对齐要求的倍数,编译器可能会在最后填充一些额外字节。

分配空间与对齐规则

示例 1:原始结构体

struct MyStruct {
double dda1;
char dda;
int type;
};

空间分配过程

  1. double dda1

    • 起始地址偏移量为 0,是 sizeof(double) 的倍数(8),满足对齐规则。
    • 占用 8 字节。
  2. char dda

    • 下一个可用地址偏移量为 8,是 sizeof(char) 的倍数(1),满足对齐规则。
    • 占用 1 字节。
    • 为了满足下一个 int 的对齐要求(偏移量为 4 的倍数),需要填充 3 字节。
  3. int type

    • 下一个可用地址偏移量为 12,是 sizeof(int) 的倍数(4),满足对齐规则。
    • 占用 4 字节。

总大小

  • 成员变量总占用:8 + 1 + 3 + 4 = 16 字节。
  • sizeof(MyStruct):16,正好是最大对齐要求(sizeof(double) = 8)的倍数,无需额外填充。

示例 2:交换成员变量顺序

struct MyStruct {
char dda;
double dda1;
int type;
};

空间分配过程

  1. char dda

    • 起始地址偏移量为 0,是 sizeof(char) 的倍数(1),满足对齐规则。
    • 占用 1 字节。
    • 为了满足下一个 double 的对齐要求(偏移量为 8 的倍数),需要填充 7 字节。
  2. double dda1

    • 下一个可用地址偏移量为 8,是 sizeof(double) 的倍数(8),满足对齐规则。
    • 占用 8 字节。
  3. int type

    • 下一个可用地址偏移量为 16,是 sizeof(int) 的倍数(4),满足对齐规则。
    • 占用 4 字节。

总大小

  • 成员变量总占用:1 + 7 + 8 + 4 = 20 字节。
  • 为了满足最大对齐要求(sizeof(double) = 8)的倍数,结构体末尾需填充 4 字节。
  • sizeof(MyStruct)24

在上述两个例子中,填充字节的数量差异明显:

  1. 示例 1:填充了 3 字节。
  2. 示例 2:填充了 7 + 4 = 11 字节。

这表明,字段的声明顺序会显著影响结构体的内存布局和填充字节的数量。合理调整字段顺序可以显著减少内存浪费。

结构对齐的自定义控制与 #pragma pack

在默认情况下,VC 提供了对齐机制以提升 CPU 访问速度,但在某些场景下,我们需要调整或屏蔽这种默认对齐方式。通过 #pragma pack(n) 指令,可以自定义结构体的对齐方式。

  1. 成员变量的起始地址偏移规则

    • 如果 n 大于等于 变量类型占用的字节数,则偏移量遵循默认对齐规则。
    • 如果 n 小于 变量类型占用的字节数,则偏移量为 n 的倍数。
  2. 结构体总大小的约束规则

    • 如果 n 大于等于 所有成员变量的对齐需求,则结构体总大小是最大对齐单位的倍数
    • 如果 n 小于 所有成员变量的对齐需求,则结构体总大小是 n 的倍数。
#pragma pack(push)  // 保存当前对齐状态
#pragma pack(4) // 设置为 4 字节对齐

struct Test {
char m1;
double m4;
int m3;
};

#pragma pack(pop) // 恢复之前的对齐状态
  1. char m1

    • 起始地址偏移量为 0,是 sizeof(char) 的倍数(1),占用 1 字节。
    • 下一成员变量需要满足 4 字节对齐,因此填充 3 字节。
  2. double m4

    • 偏移量为 4,满足 4 字节对齐规则,占用 8 字节。
  3. int m3

    • 偏移量为 12,满足 4 字节对齐规则,占用 4 字节。

总大小

  • 成员变量占用:1 + 3 + 8 + 4 = 16 字节。
  • 满足 4 字节对齐,无需额外填充。
  1. 结构的总大小
    • 总占用为 20 字节,不是 16 的倍数,需填充 4 字节。
    • 最终 sizeof(Test)24

通过这种方式,我们可以灵活控制结构体的对齐方式,以满足内存优化或跨平台兼容的需求。

sizeof 的用法总结

在 C++ 中,sizeof 是一个非常强大的工具,用于确定数据类型、变量、数组、指针、结构或类的大小。但它也容易引发理解误区。以下总结 sizeof 的常见使用场景与注意事项:

A. 数据类型或一般变量

  • 示例:sizeof(int)sizeof(long)
  • 注意:结果可能因系统或编译器而异。例如:
    • 在 16 位系统中,int2 字节;
    • 在 32 位或 64 位系统中,int 通常占 4 字节。

B. 数组与指针

  • 示例:
    int a[50];         // sizeof(a) = 4 * 50 = 200
    int *p = new int[50]; // sizeof(p) = 4(在 32 位系统中,指针占 4 字节)
    注意
    • 数组名是数组的起始地址,但 sizeof 会计算整个数组的大小。
    • 对于指针,sizeof 返回指针本身的大小,而不是指针指向的内容。

C. 结构体与类

  1. 静态成员

    • 静态成员不参与实例的大小计算,因为它们的存储与实例无关。
    • 示例:
      class Test {
      int a;
      static double c;
      };
      sizeof(Test) = 4; // 静态成员不影响结果
  2. 空结构体或类

    • 即使没有成员变量,结构体或类的大小也为 1,以确保实例有唯一的地址。
    • 示例:
      class Empty {};
      sizeof(Empty) = 1;

D. 函数参数与返回值

  • 示例:
    int func(char s[5]) {
    return 1;
    }
    sizeof(func("1234")) = 4; // 返回值类型为 int,因此结果为 4
    注意
    • 数组参数在函数中会被视为指针,sizeof 返回指针的大小(而不是数组本身的大小)。

实用建议

  1. 分析内存布局:理解结构体的对齐规则与大小计算方法,能帮助定位潜在的性能问题与内存浪费。
  2. 跨平台兼容:当开发需要在多种系统或编译器下运行的程序时,应避免直接依赖 sizeof 的结果,而是明确指定数据类型的大小(如使用 stdint.h 提供的固定大小整数类型)。
  3. 善用对齐指令:通过 #pragma pack 调整对齐方式,可以在性能和内存使用之间找到平衡点。
查看或添加留言

C# 中的接口与抽象类

· 阅读需 6 分钟

一、接口的定义

定义:接口是一个协定,定义类或结构间交互的标准。实现接口的类或结构必须遵守该协定。

简单来说,接口是一种明确的约定,用于规定类与类之间的交互方式。初次接触“类通过接口交互”时,可能会误以为接口只是类公开的方法。但实际上,接口是独立于具体类的定义,它为类之间的交互提供了一种抽象标准。

为什么使用接口?
直接让类与类交互似乎也可以完成任务,但接口的引入具有以下优势:

  • 抽象性:接口抽象了类之间的交互内容,便于逻辑分离,增强程序的可维护性。
  • 扩展性:通过为接口开发新实现,可以轻松扩展系统功能。
  • 约束性:接口定义了实现者必须遵守的规则,有助于减少耦合。

接口的特点

  • 接口只包含成员的定义,不包含实现。具体的实现由实现接口的类或结构提供。
  • 接口的成员包括:方法、属性、索引器、事件。
  • 注意:接口不包含字段。

接口不可更改:一旦接口发布,不能对其进行修改,否则会破坏现有代码。

示例代码:以下是一个接口的定义示例:

using System;

public delegate void Change(object sender, object eventArgs); // 定义一个委托

public interface IBroker // 定义股票经纪人接口
{
string GetRating(string stock); // 获取评级的方法(未实现)

decimal PricePerTrade { get; set; } // 定义每股价格的属性

decimal this[string stockName] { get; set; } // 定义索引器

event Change PriceChange; // 定义一个事件
}

二、接口与抽象类

在功能上,接口抽象类都有抽象定义的作用,但它们的使用场景和特点不同。以下是二者的对比:

特性接口抽象类
实现只包含成员的定义,不包含实现可包含部分实现或完全抽象定义
继承限制类或结构可以继承多个接口子类只能继承一个抽象类
适用场景为不相关的类提供通用功能定义密切相关的类之间的共同行为
功能粒度小而精练,设计小功能单元设计大功能单元
扩展性一旦发布不能更改,需通过新接口扩展可通过继承实现多版本扩展

使用建议:

  • 如果需要为多个类提供小而通用的功能块,选择 接口
  • 如果要定义一组紧密相关的对象行为,或需要为组件设计多个版本,选择 抽象类

三、接口的实现

接口的实现分为隐式实现显式实现

  • 隐式实现:类实现单个接口时可直接定义接口成员。例如:
    public class TestBroker : IBroker
    {
    public string GetRating(string stock) => "Buy";
    public decimal PricePerTrade { get; set; }
    public decimal this[string stockName] { get; set; }
    public event Change PriceChange;
    }
  • 显式实现:当类实现多个接口,且接口成员名称冲突时,使用显式实现。显式实现需通过接口的完全限定名。例如:
    public decimal IBroker.PricePerTrade
    {
    get { return pricePerTrade; }
    set { pricePerTrade = value; }
    }

完整实现示例:

public class TestBroker : IBroker
{
private readonly Hashtable hash = new Hashtable();
private decimal pricePerTrade;

public TestBroker(decimal initialPrice) // 构造函数
{
pricePerTrade = initialPrice;
}

// 隐式实现接口的方法
public string GetRating(string stock)
{
return "Buy";
}

// 显式实现接口的属性
decimal IBroker.PricePerTrade
{
get => pricePerTrade;
set
{
pricePerTrade = value;
PriceChange?.Invoke(this, value);
}
}

// 实现索引器
public decimal this[string stockName]
{
get => (decimal)hash[stockName];
set => hash[stockName] = value;
}

// 实现接口的事件
public event Change PriceChange;
}

四、接口的多态

当多个类实现相同的接口时,可以通过接口引用实现多态。以下示例展示了如何实现接口的多态访问:

public class InterfaceTester
{
public static void Main(string[] args)
{
ArrayList brokers = new ArrayList
{
new FirstBroker(7.21m), // 添加第一个实现接口的类
new SecondBroker(12.3m) // 添加第二个实现接口的类
};

foreach (IBroker broker in brokers)
{
broker.PriceChange += (sender, eventArgs) =>
{
Console.WriteLine($"Price changed to {eventArgs}");
};

broker["ABC"] = 15.55m; // 使用索引器
broker.PricePerTrade = 20.00m; // 设置属性
}
}
}

总结

  • 接口用于定义类或结构间的协定,适合提供小而通用的功能块。
  • 抽象类提供基础功能实现,适合设计关系密切的类之间的共同行为。
  • 接口的多态性可以通过接口引用实现,进一步增强代码的灵活性和可扩展性。
查看或添加留言

生物进化与软件演化

· 阅读需 26 分钟

作为一名软件工程师,同时拥有生物医学的研究背景,我对计算机科学与生物学都有着浓厚的兴趣。平日里,这两方面的书籍常常是我的读物。虽然这两门学科看似风马牛不相及,但它们的某些思想却能够相互借鉴、启发。这篇文章将分享进化论的相关理论如何影响了我的程序设计思路,以及在学习编程思想后对进化论的新认识。

一、基因突变与物竞天择思想在编程算法中的应用

计算机科学中有一门学科叫做遗传算法,它系统地探讨了如何将遗传与进化的思想引入计算机科学,比我的尝试要深入得多。不过,在正式接触这门学科之前,我曾经有过一些朴素的探索。

最初接触这个问题的契机是这样的:一个朋友刚学会了黑白棋(Othello),邀请我对战。虽然他是个新手,但我的水平更糟糕,总是输给他。于是我萌生了一个想法:既然下棋不行,那不如发挥编程的专长,写个程序来击败他。然而,问题在于我对黑白棋的规则和策略了解有限,怎么确保程序能比我更强呢?

黑白棋规则相对简单,基本算法容易实现。我最初的想法是让计算机模仿我的思路下棋,借助它的计算速度优势,理论上应该能比我强一些。但这还不够,我希望程序能通过某种方式自我学习、不断提升棋艺。这个想法受到了进化论中基因突变和物竞天择思想的启发:让不同的程序进行对战,淘汰表现差的,保留和优化表现好的,从而推动棋力的逐代提高。

我的方法是提取可能影响棋局胜负的参数,比如棋子的落点、每颗棋子周围的空格数量、己方可落子的数量、对方可落子的数量等等。虽然我对这些参数的实际作用心中无数,但参数一定要“宁多勿漏”,程序会自动筛选出那些有用的参数。这些参数可以看作程序的“基因”,不同程序的区别就在于这些参数的具体值有所不同。

接下来,我组织了几十个程序进行循环赛,并将成绩居于后半的程序淘汰;成绩靠前的程序保留它们的“基因”,还可以通过“基因重组”和“随机突变”等方式,调整参数的值,生成新的后代。如此一代代模拟进化,程序的棋力也随之逐步提高。

最终,这个程序经过一段时间的自我训练,成功战胜了我的朋友。这让我感到相当自豪!

然而,这种“进化式”程序设计也暴露了一些明显的问题,最主要的是效率低下。程序棋力的提升非常缓慢。这点其实不难理解,毕竟生物界花了数十亿年的时间才进化到今天的水平。即便我的程序效率远高于自然进化,想取得显著的进步也可能需要数百年的时间。

另外,每代程序的“种群规模”太小,仅有几十个样本。相比之下,生物界每代往往有几百万甚至几亿的个体,这样的大样本量才能提供足够的多样性。我的程序等于在“近亲繁殖”,甚至可能导致棋力一代不如一代。这两个问题的根源在于计算机的运算与存储能力有限。

遗传算法中提到的解决方案远比我的方法成熟,但也存在类似的局限性。对这一领域感兴趣的读者不妨深入研究相关书籍。

二、对进化论的反思

假如我基于基因突变和物竞天择思想设计的黑白棋程序,一直按照既定规则运行,经过几万年的演化,是否可能进化成能够下五子棋或者其他棋类的程序呢?这一问题引发了我对进化论的重新思考。

达尔文在《物种起源》中提出了两大核心假设:同源说和进化论。同源说认为地球上的所有物种都源于同一个最初的生命体,这一观点得到了较为广泛的认可,特别是在基因学和分子生物学的支持下。比如,人类与单细胞细菌之间也存在高度同源的基因,这为同源说提供了强有力的佐证。

然而,进化论,即通过基因突变和物竞天择解释物种多样性的假说,却从一开始便饱受质疑。虽然 DNA 的发现为进化论提供了一定支持,但仅凭基因突变与自然选择能否完全解释生物的演化仍存在诸多争议。

进化论主要依赖三大证据:比较解剖学、古生物学(化石记录)以及胚胎发育的重演律。然而,这些证据并非无懈可击。

  1. 比较解剖学 人类和猴子的骨骼结构高度相似,这是比较解剖学的经典证据之一。有人认为这是一种循环论证:人和猴子的骨骼相似是因为它们有共同的祖先,而为何有共同祖先的原因又归结于骨骼的相似性。然而,我认为骨骼相似本身是一种客观现象,无需证明,也无需与进化论挂钩。这一现象可以很好地支持同源说,但却无法解释生物演变的具体过程。

  2. 古生物学(化石记录) 按照基因突变理论,生物的演化应是一个缓慢而连续的过程。然而,化石记录却提供了截然不同的图景。大多数化石展示的是物种突然出现并长期保持稳定的状态,过渡类型的化石寥寥无几。例如,始祖鸟是为数不多的“过渡类型”化石之一,但与成千上万的物种化石相比,显得过于稀少。

更为显著的是所谓的“寒武纪大爆发”现象:在30亿年前至5亿年前的漫长时间里,化石记录几乎全部是单细胞生物。然而在5亿年前,各种复杂的多细胞生物似乎在一夜之间出现在海洋中。这种突发性和缺乏过渡类型的化石记录,更多地揭示了基因突变理论的局限性。

  1. 胚胎发育的重演律 重演律认为胚胎发育过程会重现物种的演化历程。然而,如果进化的驱动力是基因突变,原有的信息理应被替代或遗忘,那么胚胎为何会保留并重现这些信息?除非生物的演化并非完全依赖基因突变,而可能涉及更为复杂的机制。

回到文章开头的问题:如果让我的黑白棋程序持续进行基因变化,它能否演化为下五子棋或其他棋类的程序?答案显然是否定的。因为黑白棋程序的基因设定是专注于黑白棋规则的,在没有外界干预或新信息输入的情况下,它的“进化”只会局限在原有的范围内。这表明,“基因突变”并不足以解释复杂系统如何从无到有地发展出新的功能。

综合来看,进化论的三大证据在解释物种多样性时都存在局限性。尽管基因突变和自然选择无疑在演化中起到了重要作用,但将其作为唯一机制,可能无法全面揭示生物演化的真实过程。或许,探索其他未被完全理解的生物机制,才是进一步解答这些谜题的关键。

三、程序是如何“进化”的

虽然我的黑白棋程序无法自行进化成五子棋程序,但在我的干预下,这种“进化”却完全可行。

我的程序使用 C++ 编写。熟悉 C++ 的人都知道,即使是一个简单的程序,也通常会运用面向对象和泛型编程等思想,我自然也不例外。那么,如果我打算开发一个五子棋程序,应该如何着手呢?

黑白棋和五子棋有许多相似之处,比如棋盘的类型、棋子的基本属性等。如果从头重新创建一个五子棋程序,显然既费时又低效。

最直接的办法是复制原来的黑白棋代码,然后在此基础上修改。然而,这种方法存在明显的问题:代码冗余。两份程序会包含大量重复代码,不仅浪费资源,还会给后续的维护带来麻烦。比如,如果我优化了黑白棋程序的某部分代码,还需要手动将改进同步到五子棋程序中,这显然不够灵活。

得益于面向对象的编程方法,这个问题可以迎刃而解。我可以将黑白棋程序中的核心功能抽象为一个基类,并在此基础上派生出五子棋的类。五子棋中与黑白棋不同的特性和方法,可以通过覆写(override)实现。在这种设计下,基类的代码仍然存在,但被覆写的部分在五子棋类中将被替代或补充。

更有趣的是,某些基类的功能并不会因为派生类的覆写而完全消失。例如,子类的构造函数通常会先调用基类的构造函数,然后再执行自己的逻辑。这意味着,父类的一些特征和行为仍然能够保留,并在子类实例化的过程中显现出来。

这种基类与派生类的关系,很容易让人联想到胚胎发育的重演过程。在生物学中,胚胎在发育早期会呈现出某些祖先的特征,而随着发育的进行,这些特征逐渐被新的结构和功能取代。这与程序设计中基类的部分代码被派生类覆盖,却依然能够影响派生类的实例化过程有异曲同工之妙。

这种程序设计方式,不仅减少了代码的重复性,还保留了灵活扩展的可能性。就像生物演化中的“保守与创新并存”,程序设计也在继承已有功能的基础上不断发展出新的特性。

四、对生物的进化的反思

参考程序自动“进化”的规律,我们或许可以提出一种新的假设:基因突变仅能在同一物种的小范围内引起改良,而真正导致质的变化的,除了量的积累外,更多的可能是类似程序中“继承”或“重组”这样的基因操作。

现代生物学研究发现,高等生物的染色体中存在大量所谓的“无用”基因片段。这些片段虽然在当前看似不起作用,但它们可能在生物的进化历史中曾经扮演过重要角色。随着生物体获得新的基因片段,这些新片段可能具有与旧片段类似的功能,但表现出更高的优先级,从而抑制了旧片段的活性。

当新基因片段主导生物体的功能后,旧片段逐渐失去作用,成为看似“无用”的遗留。然而,这些片段并未被完全淘汰,它们的存在暗示着一种“历史记忆”,记录了生物在进化过程中经历的不同阶段。这一机制与软件开发中的代码继承有异曲同工之妙:旧的功能被保留但不再被调用,而新的功能覆盖了它们的位置。

那么新基因片段是从何而来的呢?有很多种可能性,比如:

  • 有性繁殖的重组:有性繁殖是基因多样性的重要来源。在此过程中,父母的染色体通过交叉和重组,形成了包含新组合的染色体。而在某些特殊情况下,可能会引入完全新颖的基因片段,从而为后代带来前所未有的功能。
  • 基因交换:在低等生物中,比如细菌,基因交换是一种常见现象。通过质粒转移或其他方式,细菌可以将自身的基因传递给其他个体,这种机制为基因创新提供了直接途径。
  • 病毒的作用:病毒能够将自己的基因插入宿主细胞的染色体中。在某些情况下,这些外来基因可能被宿主整合并表达,从而引入新的遗传信息。
  • 外部环境的输入:新基因片段可能通过饮食或环境途径间接进入生物体。例如,食物中的 DNA 在极特殊的情况下可能被吸收并整合进自身的基因组中。

从以上分析可以看出,生物进化并非完全依赖基因突变的随机性,而是一个更为复杂的过程。突变提供了变异的基础,但快速的进化可能更依赖于基因重组、新基因的引入,以及生物间的遗传信息共享。

这种进化机制与软件设计中的继承和扩展有相似之处:旧有的功能可以被继承和改良,新功能的引入则通过扩展实现。这种“保守与创新”的平衡,或许正是生物进化与程序设计的共同规律。

五、未来软件的发展趋势

生物用了 30 亿年的进化才发展到今天的复杂程度,而计算机软件仅仅发展了不到50年。尽管两者当前的复杂性尚无法直接比较,但在人类智慧的推动下,软件进化的速度可能远超生物进化。或许在未来百年内,我们就能看到具备自我完善能力的智能软件,其发展速度可能是生物进化的千万倍。基于这一思路,我们可以大胆预测,未来软件的发展也许会在某些方面与生物进化展现出类似的规律。

在维护大型软件时,处理遗留代码(legacy code)是一个常见的挑战。例如,我最近需要修改一段存在于公司大型软件中的老旧代码。这些代码风格极差,如变量名使用单字符表示、大量硬编码等。如果几年前的我,可能会直接选择彻底重写这段代码。然而,实践经验表明,这种做法往往得不偿失。

一个大型软件经过几十年的发展,可能有数百甚至上千人参与过开发,其代码之间交织着复杂的引用关系。贸然重写某段代码,极可能在功能上无法完全覆盖原有逻辑,进而引入更多的隐性bug。而这正如生物学中所谓的“无用基因片段”:它们表面上似乎没有功能,但实际上可能隐藏着潜在的价值或作用。

现在生物学上,研究基因片段功能的方法主要是,先把一段基因敲掉,然后看看他对生物体带来了什么影响。然而大多数基因被敲掉以后,却看不出生物体受到了明显的影响,这就是那些“无用”基因片断。这一是因为上面提到的基因有备份,备用基因会及时发挥作用,弥补缺失的基因的功能。还有就是很多基因的功能是和其他很多基因共同作用才能显现出来的,或者是在某些特殊情况下才会被显现出来的。这些基因的功能不是那么容易被观察到的,他们缺失了,或者被添加到一个本来不具备这种基因的生物体上,它们的潜在的影响或许要过很多年才能被发现。现在大家对转基因作物的戒心,这也是原因之一。(对转基因食物更大的担心可能是在于,被植入的基因片段不够稳定,更容易整合到人体基因中去,导致人类的基因被转化。)

对遗留代码的最佳处理方式,通常不是直接改写,而是通过添加包装层的方式进行优化。例如:

  • 如果原代码是一个简单的除法运算,没有处理除数为零的情况,最佳做法不是直接修改源代码,而是在其外部包裹一层新函数。新函数负责检查除数是否为零,若是,则进行错误处理;否则调用原代码执行除法。这种方法避免了对原代码行为的直接改动,同时确保新的功能可控且安全。
  • 如果需要改动的是一个类,则可以通过继承原有类,创建一个派生类。在新类中覆盖或添加所需的功能,而原有类保持不变。这样,旧代码的稳定性得以保留,而新功能通过扩展实现。

这一策略的缺点在于:会导致程序体积迅速膨胀。然而,随着存储成本的下降和计算机硬件能力的提升,程序体积不再是首要问题。维护效率和功能安全性将成为更重要的考量。

随着软件系统的规模日益庞大,完全消除bug几乎是不可能的。尤其在关键场景中,系统的任何故障都会带来不可接受的损失。在这种情况下,备份机制成为保障系统稳定性的最佳手段。硬件系统已经普遍采用备份策略,未来的软件系统也会逐步引入这一思路。例如,对于某些核心功能,软件可以采用双实现策略:由不同的开发团队设计两套独立的实现代码。在正常情况下使用其中一套,当出现异常时自动切换到备份代码。这一方法与生物基因的双拷贝特性非常相似。生物体内的基因通常成对存在,不仅是为了繁衍后代,更是为了在突变或损伤时提供冗余保障。例如,当一条基因出现缺陷时,另一条可以迅速接替其功能,确保生物体正常运作。

随着技术的持续发展,软件的进化将越来越表现出类似生物系统的特性:

  • 冗余与容错:通过多版本实现和模块化设计,软件将具备更高的容错能力。
  • 继承与扩展:老旧代码不会被轻易抛弃,而是在其基础上进行扩展和优化,形成可持续的演进路径。
  • 自我完善:在人工智能的帮助下,未来的软件可能具备自我优化和自我修复能力,实现从“人类驱动”到“自我驱动”的飞跃。

生物进化依赖基因的突变与重组,而软件进化则依靠人类的智慧和创新。当我们结合生物与软件的进化规律进行思考,也许会发现,两者最终的目标并不冲突:创造出更智能、更高效的系统,推动未来社会的发展。

查看或添加留言