ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++引用与黑盒测试:从别名到悬空引用的工程实践

C++引用与黑盒测试:从别名到悬空引用的工程实践 1. 引用到底是什么从“别名”这个词说起如果你去翻C的教科书关于引用最常见的定义就俩字别名。但很多人看完这两个字脑子里只有一个哦的感叹然后扭头就把引用和指针搞混了。我当年刚学的时候也一样直到有一天我试着把引用当指针去用编译器用一屏幕报错教育了我才真正想明白这个问题。引用最朴素的理解应该是给一个已经存在的变量再起一个名字。注意是再起一个名字而不是创建新变量。我们看这段代码int original 42; int ref original; ref 100; // 此时 original 的值也是100在这段代码里ref并不是一个独立的变量。它只是一个入口、一个别名指向original那块内存。你用ref赋值本质上就是往original的内存地址上写数据。所以ref 100执行完之后original的值当然变成了 100。这有点像生活中的外号你朋友大名王建国外号叫大壮。家里人喊王建国和球友喊大壮叫的都是同一个人。你不可能单独给大壮发一份工资然后不让王建国知道因为俩人本来就是同一个。引用和指针的关键差异我习惯用一句话总结指针是一个存着地址的变量引用是一段内存的另一个名字。从编译后的机器码角度来说引用在大多数情况下不会真的分配一块独立的内存空间存地址虽然某些复杂场景下编译器可能这么干它就是一个符号层面的别名。但是指针不同指针本身是一个变量它需要占内存里面存放的是目标变量的地址。这些差异直接决定了引用的几个特性引用必须在定义时初始化不能先声明后赋值。因为引用是别名你没有目标对象就谈不上再起一个名字。指针可以先声明之后随便指向谁。引用一旦绑定目标就不能改绑。你不可能让同一个引用今天指向a、明天指向b。指针可以随意改指向。引用不会为空。只要初始化成功了它就一定绑定着某个对象当然悬空引用另说后面专门讲。这些特性看起来像是约束但恰恰是它们让引用在函数传参和返回值时比指针安全得多。因为引用天然非空调用者不需要在函数内部做空指针检查。而指针你永远得提心吊胆地问一句万一传进来一个nullptr怎么办所以现在很多现代C的代码风格都明确主张参数能用引用就不用指针除非你真的需要可空或者可改绑这两个能力。理解了别名这个底层概念后面聊什么悬空引用、黑盒测试才有地基。2. 引用、指针和值传递三者到底差在哪儿这个问题的回答我在C相关的论坛上看到过无数遍每次都有新人问。原因不是这个问题本身多难而是很多教材讲得太抽象直接堆概念读完还是不知道怎么选。我这里换个思路用一个具体的例子来展示三者的行为差异。假设我们有个函数想把外部一个整数变量翻倍。分别用三种写法#include iostream // 值传递函数内部操作的是副本 void doubleByValue(int a) { a * 2; } // 指针传递通过地址修改原始变量 void doubleByPointer(int* a) { if (a) { *a * 2; } } // 引用传递直接在原变量上操作 void doubleByReference(int a) { a * 2; } int main() { int num 10; doubleByValue(num); std::cout 值传递后: num std::endl; // 10没变 doubleByPointer(num); std::cout 指针传递后: num std::endl; // 20变了 doubleByReference(num); std::cout 引用传递后: num std::endl; // 40变了 }看到了吗值传递的时候函数拿到的是num的一份拷贝。你在函数内部怎么改这个拷贝外面的num都无动于衷。因为它俩压根就是两个不同的变量只是恰好值一样而已。这种传递方式的好处是安全、不容易被函数内部意外修改坏处是如果对象很大比如一个几MB的数组或者结构体拷贝开销不容小觑。指针传递和引用传递都能修改原来的变量区别就在于前面提到的指针需要显式取地址、传进去之后函数内部需要解引用而且必须处理nullptr的可能性。引用则不需要这些仪式直接当作原变量使用就行。说得形象一点值传递是你复印了一份文件给我我在复印件上随便画指针传递是你告诉我文件在哪个柜子我自己去拿原件画引用传递是你直接把原件递给我我在上面画。指针是间接寻址引用是直接上手。说到这里肯定有人要问既然引用这么方便那我全用引用不就完事了还真不行有几个场景引用做不到需要表示没有对象时。引用不能为空如果想表达这个参数可能没有对应的对象你别动它那就必须用指针配合nullptr。很多可选参数的接口就是这么设计的。需要更换目标对象时。比如写一个函数根据条件让某个变量指向不同的对象。类似的场景你会希望传一个指针的指针或者引用的引用进去但引用本身不支持改绑。在容器里存可指向关系时。一个包含引用的容器是不好直接赋值的因为引用不能改绑赋值操作会有歧义。这时候用指针、智能指针更合适。还有一段非常经典的关系需要拎出来讲数组形参的退化。当你把一个数组按值传给函数时数组会自动退化成指针于是函数内部拿到的只是一个指向数组首元素的指针而不是完整数组。这时候你以为按值传递是安全的其实代价是把数组的地址复制了出去函数内部依然能通过指针操作原数组。理解引用之后再看这个就不难明白为什么很多C的数组操作接口偏爱模板引用的形式——既能拿到完整数组信息又避免了无谓的拷贝。在实际项目里我的选型习惯是这样的函数参数默认用const T常量引用需要修改外部对象时用T确实可能为空、或需要改绑时用原始指针涉及动态分配所有权时用std::unique_ptr/std::shared_ptr。这套规则我用了很多年思路清爽配合人也省心。3. 悬空引用引用最阴险的翻车方式聊完引用和指针的区别必须专门讲讲悬空引用。这是引用踩坑最重的雷没有之一。就算你对引用的语法倒背如流只要一不小心让一个引用指向了一块已经不存在的内存程序的行为就会变得鬼畜起来——注意不是立刻崩溃而是看起来正常但偶尔抽风这种问题最折磨人。先看一个最经典的翻车写法#include iostream int makeDanglingReference() { int local 999; return local; // 危险local 是局部变量函数返回后这块内存就释放了 } int main() { int ref makeDanglingReference(); std::cout ref std::endl; // 未定义行为可能打印 999也可能打印垃圾值 }这里的makeDanglingReference返回一个对局部变量local的引用。函数一结束local的生命周期就到头了它那点内存虽然还在物理上存在但已经被标记为不再有效。返回的引用就成了一个悬空引用英文叫 dangling reference。你后面访问它就像是拿着一把已经过期的钥匙去开一扇已经换了锁的门——运气好门开了运气不好撞见屋里住了别人再运气差一点直接被门把手砸到脚。用一句话给悬空引用下个定义引用的目标是超过了生命周期已经被销毁的对象。导致这种情况常见的场景有下面几类返回局部变量的引用上面的例子就是。让引用绑定到临时对象的成员。比如一个函数返回一个字符串对象你用const auto s func()没问题因为临时对象的生命周期会被延长但如果你拿到的是临时对象内部的引用延伸规则在某些情况下并不适用。容器扩容导致引用失效。std::vector在 push_back 触发重新分配内存之后之前获取的迭代器、引用、指针都会失效。如果你继续用旧引用访问元素就是典型的悬空引用。智能指针管理的对象被释放后导出的裸引用还在使用。这里我想重点展开一下std::vector扩容的场景因为项目里遇到好几次每次都是新手满脸困惑地问为什么我的程序偶尔输出是对的偶尔输出是乱码。假设你有一个vector里面存了几个整数然后你拿了一个引用绑定到v[0]接着继续 push_back 一堆数据。std::vectorint v{1, 2, 3}; int first v[0]; v.push_back(4); v.push_back(5); v.push_back(6); v.push_back(7); // 大概率触发扩容 std::cout first std::endl; // possible dangling referencevector的底层是一块连续内存它的容量不够用了就重新找一块更大的内存把旧数据搬过去然后把旧内存释放掉。那个first引用还傻乎乎地看着旧地址结果旧地址里的东西早就灰飞烟灭了。你读取它读到的可能是某个无关的残留数据甚至有可能因为内存被操作系统重新分配给了别的地方而读到完全无关的信息。更离谱的是刚刚扩容之后旧内存的内容往往还在所以第一次访问可能还能读到正确的值。等到别的地方覆盖了那块内存你再读就错了。这就是为什么这类bug的复现率忽高忽低特别难排查。那怎么拆掉这个雷我建议按下面的次序自查返回引用的函数检查返回对象是否比函数活得久。如果返回的是局部对象或局部对象的一部分想都别想直接改成返回值或者返回一个智能指针。如果返回的是成员变量的引用要保证对象的生命周期覆盖了引用的使用期。用引用去绑定容器元素要注意容器会不会被修改。如果后续有插入、删除、扩容操作最好重新获取引用。别贪图一开始那一次的便利。善用 AddressSanitizer。编译的时候加上-fsanitizeaddress当悬空引用被访问时工具能直接爆出来具体是哪一行踩到了已经释放的内存。这个工具简直是排查悬空引用这个级别bug的透视眼。别让引用跨线程乱飞。一个线程创建的对象在另一个线程里用引用访问生命周期管理会变得非常复杂不如直接用值传递或者shared_ptr。之所以强调悬空引用是因为它跟黑盒测试还真有直接关系——黑盒测试里有一个非常经典的场景一个对象从接口A传出来被接口B引用着但中间某个环节对象已经销毁了。从外部接口看代码写得天衣无缝唯独跑起来时灵时不灵。要是你只盯着接口签名看根本发现不了问题但把生命周期这条暗线捋出来之后问题会非常清楚。4. 黑盒测试到底测什么不打开盒子怎么验证质量说完了引用另一边的主角是黑盒测试。很多人第一次听到黑盒测试这个说法脑海里浮现的画面是一台黑色机箱测试人员坐在机箱外面一顿乱按。这个联想比你想的贴切——黑盒测试确实就是把被测系统当成一个不透明的盒子完全不关心内部怎么实现只通过输入输出验证行为是否符合预期。作为做了这么多年技术的人我负责任地说黑盒测试不是因为不会看代码才做的测试它是一种有价值、有边界、有独特优势的测试策略。你代码写得再烂再乱黑盒测试照样能测出外部行为是否符合规格。这就是它最大的价值所在。黑盒测试的经典方法我在实际项目里反复用的主要是这几个等价类划分把输入数据按性质分成若干个等价类从每个类里挑一个代表值来测试。比如一个登录功能账号长度要求6到20个字符。那有效等价类是6~20个字符无效等价类有少于6个字符多于20个字符和空值。每个类测一个代表就够不用穷举所有输入。精准度在于划分等价类的时候要看清楚业务规则边界、数据类型、格式限制、是否允许重复等。边界值分析大量bug都藏在边界附近。上面的登录例子6个字符、20个字符是边界里的合法值5个字符、21个字符是边界外的非法值。边界值分析要求你把这几个点老老实实测一遍因为写代码的人很容易在还是这种细节上犯错边界测试就是专门抓这种错的。状态迁移测试适用于有明显状态切换的系统比如订单系统待支付 → 已支付 → 已发货 → 已完成 → 已取消。你从外部通过操作去触发状态迁移验证每一跳是否符合预期非法迁移比如已取消的订单直接变成已完成是否能被拦截。错误推测法这个最吃经验。老测试人员拿到一个新功能哪怕还没有完整用例也能凭直觉说出这个地方可能会踩到空指针那里可能会出现编码问题。这种看来哪里容易出错就重点怼哪里的做法就是错误推测法。它的价值在于用最小成本快速找到影响面最大的bug。说到这儿有人可能问黑盒测试是不是就是点点点真不是。黑盒测试的前提是需求规格清晰。如果没有明确的需求文档你根本不知道什么算对的。黑盒测试的难度不在操作而在把需求转化成可验证的输入输出对。这一步需要抽象能力、经验和对业务的敏感度。举个例子我们组之前测一个文件上传接口。需求文档说支持不超过10MB的PDF文件。直接拿一个10MB的PDF、一个10.1MB的PDF、一个txt文件去测是初级的做法。高级一点的做法是还要测0字节的空PDF、带密码的PDF、损坏的PDF、文件名是中文的PDF、文件名超长的PDF、并发上传十来个10MB的PDF。这些用例没有一个是看代码看出来的全是站在用户视角、站在系统可能哪里会崩的角度推测出来的。黑盒测试的两个关键产出是覆盖需求的可追踪矩阵以及一份能表达什么输入得到什么输出的用例报告。前者保证需求没有漏测后者保证bug能快速定位。很多开发团队对黑盒测试嗤之以鼻觉得不碰代码、不懂实现的测试人员没有价值。但现实是系统越复杂黑盒测试越能守住用户能正常使用这条底线。你总不能天天指望着用户对你的内部实现感同身受吧5. 把黑盒测试用在引用上一个不寻常但好用的组合标题把引用和黑盒测试放在一起很多人觉得莫名其妙。但只要你仔细想想你会发现它们之间有很强的内在联系引用本身是一种看不见内部状态的机制而黑盒测试恰恰是不依赖内部状态验证行为的方法。用黑盒测试的思路来验证引用相关代码往往能测出那些靠阅读源码发现不了的问题。我直接说一个真实场景。之前写一个缓存模块对外接口大致长这样class Cache { public: void put(const std::string key, std::string value); const std::string get(const std::string key) const; };第一次写的get接口是这么实现的找不到 key 就抛异常。第二次重构的时候我把接口改成了找不到 key 返回一个静态空字符串的引用避免抛异常。逻辑上没问题但测试用例里没有覆盖get 一个不存在的 key这个场景。后来在上线压力测试的时候系统偶尔出现一个诡异的现象有些请求拿到的结果是对的有些请求拿到的结果全是同一个值。排查了很久最后发现真相是某个调用方在下游代码里缓存了get返回的引用而那个引用指向的是我们内部返回的静态空字符串变量。之后任何get到空数据的地方返回的全是同一个静态对象的引用。你从外部看每个接口返回的值都对但只要调用方把引用存下来日积月累就会出现串数据的问题。这个bug从黑盒测试的视角来看就非常清晰get这个接口对外承诺的是返回引用那它必须保证返回的引用在调用后依然有效、且不共享不合理的内部状态。黑盒测试的用例设计就不应只测输入key输出value还应该测拿到的引用在被对象修改之后之前的引用是否还指向正确内容。所以如果让我给引用 黑盒测试设计一套测试思路大概是这个顺序第一层验证引用传递的语义符合预期用前面那个翻倍函数的例子写一个用例调用doubleByReference(num)断言num的值确实翻倍了。再写一个用例调用doubleByValue(num)断言num的值没变。这些是引用最基础的语义但很多人不测直到重构的时候把某个弄丢了才翻车。第二层验证生命周期边界这是关键中的关键。设计这样的用例调用一个返回引用的函数立即使用返回值验证正确性。调用同样的函数间隔一段代码之后使用返回值中间穿插无关的操作看结果是否仍然正确。对容器里拿到的引用在容器扩容之后再访问确认程序是否崩溃、是否访问到脏数据。针对悬空引用场景用 AddressSanitizer 跑一遍同样的用例看看工具能不能抓到越界访问。这一层的目的不是测正常情况下引用好不好用而是测引用在生命周期边界上表现如何。你可以说这是压力测试也可以说这是边界值分析本质上是把生命周期当成一个隐藏的输入维度。第三层验证常量正确性用const T接收数据时如果调用方尝试修改内容编译期就会报错。但黑盒测试里没有编译器你得通过行为来验证用一个试图修改外部对象的引用参数断言修改没有生效当然这其实会被编译期拦截但测试的意义在于防止未来有人通过强制转换或者非const引用路径破坏这一层保证。我在实际项目里还有一个习惯给引用返回值的接口写专门的引用生存期测试用例文档。即使当时写不出来什么有用的断言也要把这个接口返回的引用可能在什么情况下失效在文档里写清楚。因为黑盒测试的用例会随着需求的演化不断丰富但你对接口的生命周期契约如果一开始就没说清楚后面所有测试都是盲人摸象。说穿了引用和黑盒测试的交叉点就在那个最根本的思路上你承诺了一种行为方式外部使用者不应该需要知道你内部怎么实现的但他们必须能够依靠你的行为承诺。引用承诺的是操作这个别名就是操作原对象黑盒测试承诺的是验证外部行为就是验证产品价值。6. 做一个直观的对照测试同一份代码跑三遍理论说了不少最后用一次直观的对照实验来把两条线拉起来。我写了一个小测试程序把引用的几种核心特性全部放在里面然后用黑盒测试的思路设计用例。这样你一看就懂不需要查什么文档。#include iostream #include cassert void swapRef(int a, int b) { int tmp a; a b; b tmp; } void swapPtr(int* a, int* b) { if (!a || !b) return; int tmp *a; *a *b; *b tmp; } int main() { int x 1, y 2; swapRef(x, y); assert(x 2 y 1); std::cout swapRef ok: x x , y y std::endl; swapPtr(x, y); assert(x 1 y 2); std::cout swapPtr ok: x x , y y std::endl; // 对空指针的防御应当被测试覆盖 swapPtr(nullptr, y); // 应安全返回 std::cout swapPtr with nullptr ok std::endl; return 0; }这个测试程序你可以看到黑盒测试用例设计的影子一个用例验证swapRef的互换效果一个用例验证swapPtr的互换效果一个用例验证空指针的防御行为。如果后续有人改了swapPtr的实现把空指针检查漏了那么这个用例就能捕捉到崩溃。我们要做的对照试验是把swapRef(x, y)改成swapRef(x 0, y)也就是传一个临时表达式。这种情况下编译直接报错因为非常量引用不能绑定临时值。很多新手在这里会死磕为什么要拒绝我其实道理很简单临时值是一个没有名字的短命对象你给它一个别名去修改改完这个对象就销毁了修改毫无意义。这也是引用不合常理的地方它要求在定义时绑定一个持久存在的对象。而黑盒测试里面对这样的编译错误正确的策略是把它当作一条接口契约记录下来这个函数不接受临时表达式。如果按值传递swapByValue(x 0, y)是可以正常编译的因为拷贝发生在传参之前拷贝完事临时值销毁也没关系。对比来看引用传递的目的就是消除拷贝、修改原对象所以它必须对传给谁这件事更严苛。看清楚这一点以后编译报错的时候第一反应就不是编译器有毛病而是我违反了接口设计的意图。我把这套对照实验里的映射关系列出来方便后续去设计自己的测试用例引用特性对应的黑盒测试关注点测试手段示例非空性引用参数不会收到空值正常调用、非法调用都不会崩溃绑定不变性引用不会中途指向别的对象绑定后修改原对象、验证引用始终一致修改原对象值确实被修改函数调用后断言原变量变化临时值拒绝表达式不能传给非常量引用编译期就能拦截生命周期敏感悬空引用会导致未定义行为边界测试 / ASan这张表算是我这些年用黑盒测试验证代码设计的一个小结晶。每次写接口的时候我会先问这个接口对外承诺了什么如果别人不知道内部实现单看行为能不能理解我的承诺然后针对承诺去设计测试。你在引用这儿一旦想通了这套思路后面看任何接口都能站在用户视角去挑剔它——这比单纯会写语法高出一个维度。最后分享一个实操习惯我管这种专门盯着生命周期与接口承诺的黑盒测试叫「契约测试」。不管团队用什么测试框架只要代码里有引用返回值的接口我都会试着写一个契约测试用例放到CI里断言这个引用在指定条件下不会变成悬空引用。这件事当时看着挺小题大做的后来救了我好几次——每次重构完都有一堆引用传引用、对象套对象的代码跑一遍契约测试心里就有底了。你可以把同样的事情放进自己的项目里用最小的成本守住院子最深的那个角落。
返回列表