干这个主题的人不少但真正讲透“方法中间的大括号到底是个什么东西”的教程其实不多。很多人学C从第一天就看到大括号写函数用它、写if用它、写循环也用它但你要是问他“这个大括号在语法上到底扮演了什么角色为什么在方法中间出现一对独立的大括号也合法”他多半会愣一下。我们今天就把这个事掰开揉碎地说一遍。这对玩意不叫“大括号”那么随便在C的语法体系里它有一个正式名字——代码块block也叫复合语句compound statement。它不只是给代码分组的符号它直接决定了变量的生死、资源的释放、以及整个程序的作用域结构。本文将按照一个C从业者的实际视角从语法本质、作用域机制、典型应用场景、到坑人细节逐层拆开。1. 代码块的本质语法上它到底是什么很多讲解一上来就讲作用域但我想先花点篇幅把语法层面的身份说清楚。因为你对一个事物本质的理解决定了你能不能举一反三。1.1 大括号在C里的三种身份别只认一种在C中“{}”这对字符根据出现的位置不同可以分为三种身份。第一种就是今天的主角——代码块。它出现在函数体、循环体、if分支体、或者在函数内部任何需要独立分组的地方。它把多条语句“打包”成一个复合语句。它的核心属性是在语法树上它代表一个作用域。第二种是初始化列表initializer list。比如std::vectorint v {1, 2, 3};这里的{1, 2, 3}不是代码块它是一个表达式级别的语法结构。你甚至可以说它是“构造函数的参数的一种给法”。在C11之后{}还升级成了通用初始化语法int x{0};这种形式的{}是初始化器不是代码块。第三种是聚合体初始化/结构体初始化。比如struct Point { int x; int y; }; Point p{10, 20}; // 这里的{}同样是初始化器不是代码块这三者里面我们最要命的是把第一种和第二种区分开。因为在实际工程中你经常会看到一个语句长这样if (auto it map.find(key); it ! map.end()) { // 注意这个{}是代码块 process(it-second); }而在另一边std::arrayint, 5 values{{1, 2, 3, 4, 5}}; // 这个{}是初始化器前者是在“控制流语句的分支体”后者是在“给对象赋值”。如果不区分这个后面学深入了会在大括号初始化重载、lambda捕获列表等地方撞得头破血流。1.2 为什么说“方法中间的大括号”这个说法很精准题目说的是“方法中间”的大括号。这其实指的就是在一个函数体内部不是函数的定义体而是函数体内部的某段区域中你额外写了{ ... }。比如这样void foo() { int global 42; log(global); { // 这里就是“方法中间”的一个代码块 std::string temp createTemporaryString(); use(temp); } // temp在这里析构 // 继续其他逻辑 doSomethingElse(); }这种写法是完全合法的。在很多人眼里这看起来很奇怪——没事在函数中间开一对大括号干嘛其实这是一个非常实用的工具后面我会专门讲它的三个使用场景。先说语法层面C标准把代码块定义为一个“复合语句”由零条或多条语句被一对花括号包裹而成。函数体本身也可以视为一个代码块只不过它是函数的顶层代码块。所以“函数内的代码块”本质上是“嵌套的复合语句”它在语法上和函数体同类。这里有一个很多初学者不知道的细节一个复合语句在语法上的引入只和它自己有关系。即使没有if、while、for在前面单独的复合语句也合法。这就是所谓的“裸块”bare block。理解了这一点你才算真正理解了“{}在方法中间”的合法性源头。1.3 复合语句带来的第一个实际结果语句边界代码块作为一个整体起到了“把多条语句当一条语句”的作用。这个“整体性”在执行流上非常关键。举个例子在C里if语句的语法结构是if (条件) 语句。注意标准定义的语句不仅仅是一个简单的表达式语句还可以是复合语句。所以你这个语法if (ready) std::cout ready\n;和这个语法if (ready) { std::cout ready\n; trigger(); }唯一的区别就是“if后面的东西”到底是一个单条语句还是一整块复合语句。这就是为什么代码块包着的多条语句可以表现得像一条“巨无霸语句”——因为在语法树的if节点下面挂的其实只是一个“复合语句”子节点只是这个子节点内部还有无数个孙节点。这个“整体合并”的机制就是后面所有作用域和return行为的基础。2. 代码块与作用域一生二、二生三的底层规则现在我们把重心放在工程中最常打交道的部分代码块带来的作用域规则。它是C编译器进行名解析、存储分配、生命周期管理的核心依据。2.1 块作用域每一层{}就是一个隔离层C的作用域有几种块作用域、函数作用域、命名空间作用域、类作用域、全局作用域。其中块作用域就是我们这个主题的核心。它的基本规则非常直白在代码块中声明的变量只在当前代码块及其嵌套的代码块内可见。出了这个代码块名字就碰不到了。规则非常简单但它有几个延伸结论值得细想第一代码块可以嵌套每嵌套一层就是一次“遮蔽”的机会。内层的同名变量会遮蔽外层变量这是我们经常踩的坑后面单讲。第二跳转语句对代码块的行为有约束。你可以从外层跳到内层吗分情况。goto不允许跳过带有初始化的变量声明因为它会跳过作用域进入另一个作用域C标准明确说这是非法的。这就是为什么在工程中我们几乎不使用goto跨块跳转。第三try块、catch子句、lambda体本质上也都遵循块作用域规则。但它们的名字会有额外的修饰比如catch参数的名字只在catch子句的代码块内生效。举个例子你们感受一下块作用域最基本的隔离效果void example() { int x 1; { int x 2; // 内层x遮蔽外层x std::cout x; // 输出2 } std::cout x; // 输出1 }这段代码能编译、能运行x有两个实体。但如果你换个思路认为“函数里只有一个x”在这里就会出错。实际上作用域链是编译期就能解析的这属于静态词法作用域体系。C和很多现代语言一样不搞动态作用域所以”当前可见的名字“是可以通过代码结构静态确定的。2.2 变量生命周期代码块决定“什么时候死”如果说作用域决定“名字在哪儿可见”那么生命周期决定“对象在什么地方被创建、在哪里被销毁”。对局部变量而言生命周期与代码块的执行路径绑定。进入代码块时变量在其声明点被创建离开代码块时按照构造的逆序销毁析构。这里面有几个时间点值得关注如果变量声明在一个嵌套代码块中它的析构在离开嵌套块时触发而不是在函数返回时。如果代码块是通过return退出那么所有仍然活跃的、局部于代码块的对象会先析构然后才执行return返回值相关的工作。如果代码块是通过异常栈展开退出的那么沿着栈展开路径会逐一析构遇到的每一个活动对象。来看一个具体的生命周期示例struct HeavyResource { HeavyResource() { std::cout 构造\n; } ~HeavyResource() { std::cout 析构\n; } }; void useResource() { { HeavyResource res; doWork(res); } // - 这里res就析构了 std::cout 后面的代码不持有资源\n; }这段代码的核心价值在于res的存活范围不是你整个函数体而是你在函数中间人为圈出来的那个块。这种“块内资源、早早释放”的模式在内存敏感的服务端代码中特别常见。不要以为局部变量只有等到函数结束才析构那只有在代码块恰好是函数体顶层时才成立。2.3 RAII的基石代码块绑定析构点C的RAIIResource Acquisition Is Initialization资源获取即初始化之所以能成为这个语言最强大的特性之一就是因为底层有“对象析构在块结束时”这个规则支撑。想想C11推出的锁守卫void threadSafeUpdate() { std::lock_guardstd::mutex lock(mtx); sharedData; } // 离开代码块自动解锁它的解锁点就在这个块结束的位置。如果lock_guard的构造发生在函数体的顶层块那它的析构就在函数结束如果构造发生在for循环的块内那每一轮循环结束都会析构一次然后又被构造。所以你在设计资源类的时候心里要有一张“哪个代码块应该持有资源”的地图。不是所有资源都非要活到函数结束有时候让它在局部块内早早释放反而对程序整体更友好。再补充一条实际工程经验如果你发现一个函数里临时对象太多、内存峰值高得离谱不要急着改全局变量试着把那些重量级临时对象分别放进几个独立代码块里让它们的生命周期按需错开。这是一个不需要改大架构、就能显著降低高水位内存占用的小技巧。3. 代码块在控制流中的角色if、for、while背后的大括号逻辑说完了理论我们进入最常见的实战场景——控制流语句。3.1 条件语句的代码块与省略括号的诱惑if语句是最常用到的。实际中绝大多数人都喜欢写if (success) { commit(); }这个{}在语法上是if语句的分支体它同时界定了分支体代码的作用域。有一种诱惑是省略大括号if (success) commit();这在语法上完全合法看起来还特别简洁。但是我不建议在工程里这么写原因有两个。第一是后来的改动容易破坏逻辑。某天有人想在commit()后面加一行rollback()如果忘记补大括号代码就会变成if (success) commit(); rollback(); // 糟糕这行不在if的作用域里这导致rollback()无条件执行。这种bug在真实代码审查中我见过不止一次。一旦发生非常难排查因为单纯阅读代码时你的大脑会脑补出大括号的作用域但实际执行路径根本不是你脑补的样子。第二是代码块不光是语法分组它还承担了作用域的责任。如果你写了一个需要局部临时变量的分支那没有大括号根本不能声明变量因为你不能写if (success) std::string temp makeTemp(); use(temp); // 这里的temp不在上面的作用域内所以我的个人经验一直是任何控制流分支体一律显式加大括号。这不是教条而是让你和阅读者的大脑默认保持一致。3.2 if的初始化器也有自己的作用域C17为if语句增加了一个特性初始化器。你可以在if (init; condition)处声明一个变量这个变量的作用域是整个if语句块包括if和else包括else if嵌套。if (auto result lookup(name); result.valid) { use(result.value); } else { std::cout 无效结果: result.errorCode; } // 这里result已经不可见这个特性让代码更紧凑但也引入了一个新的作用域规则result的作用域是包含整个if/else结构的复合语句。这是一个“隐式块”——虽然代码里没有一对单独的大括号包住这整个if/else但初始化器的变量生命周期一直延伸到整个if/else语句结束。如果你在这个if后面还想用result对不起不行。这一点在迁移旧的C14代码时特别容易踩坑——原来C14你得在if前面先声明好变量后面还能继续用C17的初始化器形式改了作用域规则这个行为是有意为之的设计不是bug。在大型代码库升级时需要留意这种作用域收缩带来的潜在编译错误。3.3 循环中的代码块每一轮都有自己的作用域吗写for (int i 0; i 10; i) { ... }时i的作用域是整个for循环语句但这个循环体代码块每轮迭代是同一个块被执行多次。这里的生命周期规则要区分清楚i循环初始化变量在for语句的隐式作用域中整个循环期间只存在一份。循环体代码块里声明的变量每一轮迭代都重新构造、重新析构一回。一个容易晕的地方是当循环体用大括号括着时变量y在每次迭代结束就析构了下一轮迭代重新构造。如果这个变量比较重比如是一个增长型的缓冲区你希望通过循环复用来避免反复分配那就应该把它声明在循环体外部std::vectorint buffer; for (int i 0; i n; i) { buffer.clear(); fillBuffer(buffer); }如果你把buffer声明到循环体里面for (int i 0; i n; i) { std::vectorint buffer; // 每轮都分配、释放 fillBuffer(buffer); }那每轮迭代就得重复分配内存。这在性能敏感的场景下是肉眼可见的差异。while和range-based for也有同样的规律。range-based for中循环变量的生命周期其实是每轮迭代都结束重建的所以如果你在循环体里反复借用它的引用要注意不要把引用带出这一轮的范围。3.4 switch语句最容易被代码块坑到的结构switch的语句体和普通代码块不同它是一个被视为一个复合语句的整体但case标签只是跳转目标不构成独立的作用域边界。这带来一个问题switch (cmd) { case 1: int value 100; // 跨case的初始化 use(value); break; case 2: value 200; // value在本case仍可见因为它不产生新作用域 break; }这种写法在C里其实有风险。int value 100;在case 1里初始化了但我们知道switch的case分支并不会建立独立作用域。那么如果执行路径从case 2进去value也是“存在”的只不过没有进入初始化语句于是它处于“未初始化”状态。而上面的代码在case 2给value 200;赋值时看起来像重新赋值实际上是从未初始化的变量读出未知数据——标准的未定义行为。工程上正确的做法是在case里必须用大括号圈出独立作用域switch (cmd) { case 1: { int value 100; use(value); break; } case 2: { int other 200; use(other); break; } }这样每个case分支形成独立的块作用域变量互不干扰也避免了跨case跳转跳过初始化的问题。这里我多说一句很多代码规范要求“switch的每个case都要加大括号”不光是美观问题更是为了规避这种C特有的作用域陷阱。4. 裸代码块独立块的实战妙用方法中间的“隐形分区”这是今天的重头戏。函数体中间直接来一对大括号没有任何前置关键字看起来像是语法上的孤儿。但它在实际工程里是一等一的好工具。4.1 在高并发内存高占用时用块来圈住重量级临时量考虑一个函数中段会创建一个巨大的临时容器用完就扔void processData() { stepA(); std::vectorComplexObj hugeTemp; for (int i 0; i 500000; i) { hugeTemp.emplace_back(produce(i)); } consume(hugeTemp); stepB(); }如果hugeTemp在整个函数体内有效那它生命周期会拖到函数结束。如果stepB()后面还有别的重逻辑这个hugeTemp就白白占用好几MB甚至上百MB的内存。优化方案就是用裸块把它圈住void processData() { stepA(); { std::vectorComplexObj hugeTemp; for (int i 0; i 500000; i) { hugeTemp.emplace_back(produce(i)); } consume(hugeTemp); } // 悲剧这里就被释放了 stepB(); }这个改动对运行逻辑没有任何影响但内存峰值明显降低。尤其在后端服务中一个请求处理函数同时处理多个数据块时这种“块级隔离”能避免一个函数栈里堆出一堆不必要的临时代理对象。4.2 用于锁和事务的精确控制裸代码块非常适合做锁的精细控制。其实std::lock_guard的作用域是它所在的代码块你只有把持锁的lock_guard放进一个块里才能实现“早点解锁”void updateAccount(Account acc, double delta) { { std::lock_guardstd::mutex lock(acc.mtx); acc.balance delta; acc.version; } // 锁到这里就没有了 notifyListeners(acc); // 不需要锁的操作跑到外面 }如果你不圈块直接在整个函数体顶层创建lock_guard那么后面的通知操作也全程持锁白白增加了锁竞争时间。对于高并发系统这个点就是吞吞吐量的元凶。同样的道理也适用于数据库事务、文件句柄、网络连接等需要“确定性关闭”的资源。4.3 在流程的某一段临时需要helper变量有时候某段代码只是流程中的一个步骤但它需要一两个临时辅助变量。把这些临时变量放到裸块里作用是向读者传递一个信息“下面这几行的临时状态不需要贯穿整个函数。”bool isValidOrder(const Order order) { bool valid true; { const auto expected computeExpected(order); const auto actual extractActual(order); valid (expected actual); } // expected和actual在这里都不见了 return valid; }这样做最大的好处是降低阅读函数时的心智负担。读了开头的人不需要记住后面还有一对expected、actual变量在整个函数作用域里游荡。它们在块里出生、在块里消亡。半年后你回来维护这段代码只看到需要关心的那一小块区域效率会高很多。4.4 要注意闭包位置和lambda的结合C14之后lambda可以捕获外部变量。有时候你觉得某个lambda只在一小段逻辑里用了但它捕获了外部变量。C是静态作用域lambda的捕获不会延长被捕获对象在栈上的生命周期但如果你将一个lambda传给别的线程然后继续在另一个块里使用被捕获对象这个逻辑就在块边界处变得危险。void parallelJob() { int value compute(); auto task [value]() { // value按值捕获安全 use(value); }; threadPool.submit(std::move(task)); // 这里任务异步执行value已经复制进lambda内部安全 }按值捕获的lambda在C里有个好处它的生命周期不依赖外层代码块。但如果按引用捕获块退出后那个引用就成了悬垂引用。裸块在这里的意义在于**当你把按引用捕获的lambda圈在一个块里并且确保它在这个块内被同步执行完块退出后这个lambda也不应该再被使用。**这是裸块的又一个边界约束作用。在工程中我经常用裸块来传达“这个lambda的生命周期不会超出这个块”的意图从而减少悬垂引用的发生。4.5 语义化注释的辅助工具代码块还能当成一种“视觉分区”的方案使用。某些团队习惯在函数内部用裸块加注释来组织步骤void executePipeline() { // Phase 1: 校验 { validateInput(); } // Phase 2: 幂等预处理 { normalizeData(); } // Phase 3: 核心计算 { runCore(); } }这样每一阶段能访问的变量被圈在了自己所属的域里各阶段之间的临时状态不会互相污染。比在里面堆一连串注释更可靠因为注释只是给人看的而花括号既给编译器的作用域边界也给人看的分组边界。编译器知道你什么时候越界了人也能从缩进上看出阶段边界来。5. 代码块相关的常见坑从悬空else到变量遮蔽这部分内容是专门写给那些写了不少C、但还在踩各种与{}有关的坑的人的。5.1 悬空else与代码块的纠葛悬空else问题是所有C系语言的教学经典。其实悬空else本质上就是“else到底和哪个if配对”的问题。在没有大括号时else默认匹配最近的未匹配if。if (a) if (b) doA(); else doB(); // 这个else匹配的是if(b)不是if(a)使用大括号可以完全消除歧义if (a) { if (b) { doA(); } } else { doB(); }实话说现代的代码规范一般都要求if/else大括号全量带上悬空else问题更多是考试题但我在代码审查中仍然见过不少没带括号的if。只要有一次没带这种多级嵌套的悬空else就可能出现。所以从根子上治理不要省略大括号。5.2 变量遮蔽shadowing陷阱变量遮蔽是块作用域最典型的负效应之一。当内层代码块声明了与外层同名变量时内层变量会隐藏外层变量。看这个经典案例#include iostream #include string int main() { std::string name global; { std::string name inner; std::cout name \n; // inner } std::cout name \n; // global }这段代码没问题但工程里真正的危险出现在“你们以为内层修改的是外层的那个变量”时double total 0.0; for (int i 0; i n; i) { double total total items[i]; // 糟糕这里total是内层新变量且用它自己来初始化是未定义行为读取未初始化变量 }我见过真实的bug内层声明的同名变量把外部累计值遮住了导致循环结束后外部total仍然是0。因为编译器对这段代码的解释是内层变量在声明点之后接管了名字total items[i]里的total引用的是内层那个还没初始化的变量于是所有累加都建立在垃圾数据上。这种坑在大括号内特别容易发生排查起来又难又耗神。规避手法很简单**不要让内层变量和外层重名尤其不要用同类型、疑似同义的变量名重复命名。**同时可以打开编译器的-Wshadow警告在GCC/Clang中都支持它会在变量遮蔽发生时给你一个明确提示这能在CI阶段就截住问题。5.3 块级return和流程控制的交互如果你在代码块内部写了return那么这个return会立刻退出整个函数包括外层函数体。很多人误以为“return只退出当前块”特别是从Python、Java转过来的人容易有这种错觉。C里return不分层级——它固定地退出当前函数并可以携带返回值。所以这样是合法的double search() { { auto p findElement(); if (p ! nullptr) { return p-value; // return直接退出search() } } return 0.0; }这在函数中间加上裸块的场景里特别容易混淆。你写了个裸块return却跳出去了那块的析构照样会执行栈展开但函数已经结束。这里唯一要注意的就是不要在return之后还写不可达代码。编译器一般会警告。还有一种易混淆的情况是在switch的case分支里写return而不是break作用是一样的——直接退出函数。这经常是新手容易踩的点代码审查时看到case分支里既没有break也没有return要么是忘写了要么就是故意要继续走fall-through。没有{}包裹的case没法区分意图所以还是建议每个case块带上{}并写清楚控制流。5.4 什么时候可以安全省略代码块虽然我前面一直强调要大括号但我得公允地说有些场合确实可以省略而且不影响可读性和安全性。一种常见的合法省略是单个表达式、且后续不太可能被扩展的情况。比如if (ptr nullptr) throw std::runtime_error(null pointer);单语句throw省略块很简洁。但我个人仍然建议显式写块因为throw这种扰动性的控制流容易让人误解。另一种情况是for循环的循环体是空语句用分号表示。这个其实很多时候是用大括号更清楚while (*p ! \0) {} // 空循环体大括号显式表明“无操作”如果写作while (*p ! \0);分号空语句很容易和后面的代码混在一起肉眼难以区分。所以我更倾向于写大括号并加注释。在工程里如果团队约定“省略大括号只允许在单行if后直接跟一条简单表达式”并且代码审查严格执行这个约定那省略有一定可读性优势。但在没有这种约定的开源项目里我始终提倡大括号完整化。这是用一点视觉码换取较大的逻辑确定性长期维护划算得多。5.5 大括号初始化与代码块重载的边界大括号还能用作初始化器因此它和代码块之间存在语法歧义的可能。经典例子是“最令人头痛的解析”most vexing parsestd::vectorint v(); // 这是一个函数声明不是默认构造的vector这里的()不是代码块但它和代码块一样触发了C语法解析的歧义。大括号初始化版本std::vectorint v{}; // 这没问题是值初始化大括号初始化能规避大部分most vexing parse但它又带来了另一类坑——初始化列表的优先级std::vectorint v {1, 2, 3}; // 走initializer_list构造 std::vectorint w(10, 20); // 走填充构造10个20 std::vectorint z{10, 20}; // 走initializer_list2个元素10和20这个{}是初始化器核心机制不是代码块但它和代码块经常在同一个句法位置上出现导致语义分歧。所以当你看到{}时永远要问一句它是一个块还是列表初始化的一部分经验法则是**如果{}前是一个类型名或变量名、后面跟着的是表达式那多半是初始化器如果{}周围只有单独语句和;、而且后面跟着控制流关键字或;它就是代码块。**具体判断要结合上下文。这个问题永远存在没什么一劳永逸的办法只能通过规范化的写法尽量避免歧义。6. 代码块细节排查一张速查表与三个实用技巧这里整理一个面向实际工程的速查帮助你在处理与代码块有关的问题时快速定位。场景核心规则建议处理方式变量在块内声明块结束即析构需要长生命周期就把变量提升到外层块同名变量嵌套内层遮蔽外层打开-Wshadow或者避免同名switch case声明变量case不建立新作用域每个case分支手动加大括号goto跳进代码块跳过初始化非法的不要跨初始化跳转lambda按引用捕获块内变量块退出后引用悬垂按值捕获或确认同步执行完if/else省略大括号悬空else、后续扩展易错一律显式加大括号return在嵌套代码块内退出整个函数确认析构行为符合预期函数体顶层大括号与代码块作用域相同配合RAII做资源管理三个实用技巧第一把临时对象的生命周期圈出来用{ }隔离。这在性能优化时几乎是免费的取舍——代码没有任何语义变化但内存占用和锁持有时间大幅优化。我经常先跑一遍内存剖析找出那些存活范围过大的重型局部对象然后用裸块把它们的范围缩到最小。第二开启-Wshadow并在CI阶段保持警告为错误比如-Werrorshadow。它会逮住所有变量遮蔽这在长期演进的大型代码基里价值极大。第三代码块内的注释要有“边界感”。既然裸块常用来分区就在块的开头写清这个区的目的在块的结束处也加一行简短注释尤其是多个块连续出现时{ // 数据归一化阶段 ... } // end 数据归一化这其实是很多大型C项目里的约定俗成。它不改变语义但极大降低后续阅读者跟随大括号配对的心智成本。7. 写在最后的经验总结作为一个常年写C的人我坦率地说代码块这玩意看起来简单得不能再简单但真正把它用明白的开发者并不占多数。很多人写了几年代码仍然只会把{}当成“语法上的配对符号”不知道它是一个语法树上的作用域节点、是一段变量生命周期的容器、是资源析构时机的锚点。我刚工作那阵子也犯过一个经典错误在某段高并发代码里把一把锁的lock_guard直接写在了函数的顶层块导致后面一大串非必要临界区全都排长队吞吐量掉了好几百。改成用裸代码块圈住临界区之后性能立刻恢复。那次经历让我真正理解了“代码块的作用域不只是供编译器检查名字的它直接决定对象在内存里活多久、锁在手里攥多久”。后来我越用越发现代码块是C程序员表达“意图”的最廉价手段之一。你想让一个临时对象早点死加一对{}你想让一段逻辑边界清晰加一对{}你想防止switch跨case串变量加一对{}。一个看起来朴素的语法结构背后是整个生命周期和作用域体系。如果你从这个角度重新审视自己写过的C代码把那些只当成“格式要求”的大括号看成真正影响程序行为的机制你对C底层逻辑的理解会上升一个台阶。最后送你一个小建议现在就翻翻你最近写过的几个函数看看里面有没有重型临时对象或锁变量生命周期过长的现象。如果有用裸块优化一下实测对比下内存峰值或者锁等待时间数据会让你对{}刮目相看。