前阵子接手一个维护了六七年的 C++ 解决方案,客户分三档,基础版、高级版、旗舰版,功能模块按授权等级走。接手后第一反应是加编译开关来隔离功能,结果发现项目里已经有一套宏定义,名字叫_VERSION_A、_VERSION_B,散落在不同的头文件和项目属性里。更乱的是,有的项目用 A、B 区分,有的项目用 1、2 区分,还有的直接在源码里写#if 0。折腾了一周才理清楚。
这件事让我重新把 Visual Studio 里「宏」和「预处理」相关的知识完整捋了一遍。日常开发中,这两个词被用得很随意,宏录制、MSBuild 属性宏、C/C++ 预处理器宏经常被混在一起。这篇就按我实际用下来的经验,把用户自定义宏和给项目添加宏预处理这套东西讲透,包括界面操作、工程文件级别的配置、踩过的坑,以及一些能让维护成本大幅降低的做法。
1. 先分清 Visual Studio 里三套容易混淆的「宏」机制
很多教程把「宏」当成一个笼统的概念,实际上 Visual Studio 里至少有三种完全不同的机制,作用范围、生效阶段和定义方式都不一样。搞混了会出大问题。
1.1 历史遗留的「录制宏」:理念很美好,现实很骨感
早年间用 Visual Studio 的人可能还记得,工具菜单下有个「宏」,可以录制你在编辑器里的操作序列,然后回放。这个功能在 Visual Studio 2010 及更早版本里还能用,本质是写一段自动化脚本驱动 IDE。到 Visual Studio 2012 之后,官方把宏录制功能整个移除了,替代方案是 VSIX 扩展和自动化模型。
所以如果你在网上搜「Visual Studio 自定义宏」看到老帖子让你去「工具 -> 宏 -> 录制临时宏」,别找了,新版本没有这个入口。这个历史遗留功能对现在大多数人的实际项目也没什么参考价值,真正在项目里大量使用的是下面两套机制。
1.2 MSBuild 属性宏:项目文件里的「变量」
打开一个 .csproj 或 .vcxproj 文件,你会看到一堆$(SolutionDir)、$(Configuration)、$(Platform)之类的东西,这就是 MSBuild 属性宏。它们本质上不是编译器的东西,而是构建系统的变量,在项目文件被解析的时候完成替换。
我们完全可以定义自己的属性宏。举个最简单的例子,在 .vcxproj 里加一段:
<PropertyGroup> <MyCustomOutputDir>$(SolutionDir)..\output\</MyCustomOutputDir> </PropertyGroup>后面在输出路径、生成事件、文件复制步骤里都能用$(MyCustomOutputDir)引用。这套机制的生效阶段是「构建系统解析项目文件时」,和编译器预处理是两个完全不同的时间点。
1.3 预处理器宏:编译器真正消化掉的「宏」
这才是 C/C++ 和 C# 日常开发里说的「宏」。C/C++ 里是#define出来的符号,配合#ifdef、#ifndef、#if defined(x)做条件编译;C# 里是条件编译符号,配合#if使用。
「给项目添加宏预处理」这句描述,在 Visual Studio 的操作语境里,最常见的操作就是:项目右键 -> 属性 -> C/C++ -> 预处理器 -> 预处理器定义,在那里填一串宏名。填进去以后,编译器编译每个 .c/.cpp 文件的时候,都会先经过预处理器,把宏展开、把#if分支吃掉,然后才进入真正的编译阶段。
下面这张表可以帮你快速定位平时说的「宏」是哪一种:
| 机制 | 定义位置 | 生效阶段 | 典型写法 | 作用范围 |
|---|---|---|---|---|
| 录制宏(已移除) | IDE 宏系统 | 编辑器操作时 | 录制的操作序列 | IDE 内自动化 |
| MSBuild 属性宏 | 工程文件/属性面板 | 构建系统解析时 | $(CustomVar) | 整个项目/解决方案 |
| 预处理器宏 | 源码/项目属性/命令行 | 编译器预处理时 | #define FEATURE_X | 单个文件或整个项目 |
搞清楚这三套机制后,你会发现很多问题根本不是「宏不生效」,而是你用了 A 机制去实现 B 机制该做的事。
2. 给 C/C++ 项目添加预处理器定义:从属性页到源码的完整链路
这部分是「给项目添加宏预处理」最直接的落地操作。我会从界面操作讲起,然后说清楚它背后实际改的是什么文件,再延伸到源码里的条件编译。
2.1 在项目属性页里添加预处理器定义
操作路径:解决方案资源管理器里选中项目 -> 右键 -> 属性 -> 配置属性 -> C/C++ -> 预处理器 -> 预处理器定义。
点开右边的下拉框,编辑,会看到默认已经有一串宏,比如:
WIN32 _DEBUG _CONSOLE多个宏之间用分号分隔。在这里新加宏,注意两个细节:
第一,配置和平台是两个维度。属性页顶部有「配置」下拉框(Debug/Release/All Configurations)和「平台」下拉框(Win32/x64/ARM64)。大部分人习惯直接选「所有配置」加,但如果某些宏只在 Debug 下才需要,就分别配。这个操作背后,实际上是给不同的配置平台组合写了不同的 XML 配置节点。
第二,宏名后面可以带值,但不是等号,而是用宏名直接跟着值。比如你想定义一个版本号宏,写:
MY_VERSION=5预处理器生成的效果是#define MY_VERSION 5。在代码里可以用#if MY_VERSION >= 5判断。
2.2 属性页的改动到底写进了哪里
图形界面点完,本质上是改了 .vcxproj 文件里的ClCompile节点。上述操作对应的 XML 长这样:
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'"> <ClCompile> <PreprocessorDefinitions>WIN32;_DEBUG;_CONSOLE;MY_VERSION=5;%(PreprocessorDefinitions)</PreprocessorDefinitions> </ClCompile> </ItemDefinitionGroup>注意末尾的%(PreprocessorDefinitions),这是 MSBuild 里的「继承」占位符,意思是把我这个节点里的定义和从父级/其他来源继承到的定义合并。如果你把这一整行替换掉但漏了%(PreprocessorDefinitions),很可能会把 SDK 或 CMake 工具链注入的宏也冲掉,编译直接翻车。
2.3 源码层面:#define和属性宏的区别与配合
项目属性里定义宏和源码里写#define,最终都是交给预处理器处理,但行为上有明显差别。
项目属性里定义的宏,对所有参与编译的源文件都生效,在编译器命令行的/D参数里能看到(可以在「命令行」页面点击「查看方案」确认)。源码里的#define只对当前文件生效,而且要求定义语句出现在使用语句之前。
最常见的用法是组合:属性页声明开关宏,源码里用开关宏控制条件编译。比如项目属性里定义了FEATURE_ADVANCED,代码里这样写:
#include <iostream> #ifdef FEATURE_ADVANCED void runAdvancedMode() { std::cout << "Running advanced features..." << std::endl; // 高级功能实现 } #else void runAdvancedMode() { std::cout << "Running basic mode..." << std::endl; // 基础功能实现 } #endif int main() { runAdvancedMode(); return 0; }编译的时候,如果项目属性里有FEATURE_ADVANCED,那么#ifdef FEATURE_ADVANCED为真,编译的是第一个分支;如果不定义这个宏,编译的是第二个分支。这就是「宏预处理」在 C/C++ 项目里的核心工作方式。
2.4 区分预置宏和自定义宏
Visual Studio 的编译器自带了一批预置宏,不需要你手动定义。常见的几个:
| 宏 | 含义 |
|---|---|
_DEBUG | Debug 配置下自动定义,Release 下不定义,常用于调试断言的开关 |
NDEBUG | Release 配置下自动定义,会关闭assert()宏的断言检查 |
_WIN32 | 针对 Win32 平台自动定义 |
_UNICODE | 使用 Unicode 字符集时定义 |
_MSC_VER | 编译器版本号,例如 1930 表示 Visual Studio 2022 |
__cplusplus | C++ 编译模式下定义为语言标准版本号 |
有资料把_DEBUG写成DEBUG,这是错的,不是一回事。_DEBUG是微软工具链约定,DEBUG是很多开源库自己定义的约定。写条件编译的时候,最好用_DEBUG或项目里统一的自定义宏,不要混用DEBUG,否则在某个静态库里可能就会出现「我以为定义了,实际没定义」的诡异问题。
3. 在 MSBuild 属性层自定义宏:一处定义,全链路生效
只把宏写在「预处理器定义」里,影响范围还局限于编译环节。实际项目里,宏开关往往需要同时控制编译产物——比如输出文件名带不带后缀、资源版本号、生成事件要不要跑某个脚本。这时候就必须把宏提升到 MSBuild 属性层,让整个构建链路都能感知到它。
3.1 为什么需要 MSBuild 层的属性宏
举个我踩过的例子。之前做一个桌面应用,需要给不同客户出不同产品名。早期方案是直接改 VCXPROJ 里的<TargetName>输出去,再改资源文件里的版本号,再改预处理器定义。三处手动同步,每次发布都要提心吊胆。
后来统一改用 MSBuild 属性宏:定义一个PRODUCT_SUFFIX属性,然后所有需要差异化配置的地方都引用它。改一个地方,输出文件名、资源脚本、预处理器定义全部跟着变。
3.2 在工程文件里自定义属性宏
用 .vcxproj 举例,在第一个<PropertyGroup>附近加:
<PropertyGroup> <ProductSuffix>_standard</ProductSuffix> <EnableAdvancedLog>false</EnableAdvancedLog> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'"> <ProductSuffix>_pro</ProductSuffix> <EnableAdvancedLog>true</EnableAdvancedLog> </PropertyGroup>然后在输出文件名里引用:
<PropertyGroup> <TargetName>MyApp$(ProductSuffix)</TargetName> </PropertyGroup>编译 Product 配置时,生成的可执行文件名就会是MyApp_pro.exe。这就是属性宏的基本用法——构建层面的「变量」。
C# 项目(.csproj)同理,可以在<PropertyGroup>里加自定义属性,然后在DefineConstants里引用:
<PropertyGroup> <EnableTelemtry>true</EnableTelemtry> </PropertyGroup> <PropertyGroup Condition="'$(EnableTelemtry)'=='true'"> <DefineConstants>$(DefineConstants);WITH_TELEMETRY</DefineConstants> </PropertyGroup>这段 XML 的逻辑是:先判断属性EnableTelemtry是否为 true,如果为 true,就在原有条件编译符号$(DefineConstants)的基础上,追加一个WITH_TELEMETRY。C# 代码里:
#if WITH_TELEMETRY TelemetryClient.Initialize(); #endif3.3 Condition 条件是属性宏的最强搭档
MSBuild 属性宏配合 Condition,能实现非常灵活的矩阵配置。最常见的写法是「配置 + 平台」组合判断:
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'"> <EnableStackTrace>true</EnableStackTrace> </PropertyGroup>这样的写法我用下来有几个心得:
- 不要在命令行或者脚本里到处写死配置,把所有组合收敛在工程文件的 PropertyGroup 里,维护起来最省心。
- Condition 里字符串比较区分大小写,
'Debug'和'debug'不是一回事,$(Configuration)的实际值以项目文件里的配置名为准。 - 条件写多了以后,可以用
属性管理器(视图 -> 属性管理器) 查看当前配置生效了哪些属性,比直接读 XML 直观得多。
3.4 把 MSBuild 属性喂给预处理器
这是把两套机制打通的关键一步。既然 MSBuild 属性宏是构建期变量,预处理器定义是编译期开关,那自然可以把它俩接上。
在 .vcxproj 里找ClCompile的PreprocessorDefinitions,把自定义属性写进去:
<PropertyGroup> <FeatureLevel>advanced</FeatureLevel> </PropertyGroup> <ItemDefinitionGroup> <ClCompile> <PreprocessorDefinitions>FEATURE_LEVEL=$(FeatureLevel);%(PreprocessorDefinitions)</PreprocessorDefinitions> </ClCompile> </ItemDefinitionGroup>这样生成的效果是编译器命令行出现/D FEATURE_LEVEL=advanced,代码里可以:
#if FEATURE_LEVEL == advanced // ?? #endif注意上面这段代码有问题——#if里不能直接比较字符串,#define FEATURE_LEVEL advanced在预处理阶段不会变成可比较的整数。更可靠的做法是让预处理宏只做「是否定义」判断,或者使用整数常量:
// 预处理器定义写 FEATURE_LEVEL=2 #if FEATURE_LEVEL >= 2 // 高级功能 #elif FEATURE_LEVEL == 1 // 中级功能 #else // 基础功能 #endif我要强调的是:能用#ifdef判断「是否定义」就尽量别用#if 值判断,能少一个坑就少一个坑。如果一定要用值判断,保证属性值永远是数字,不要写带引号的字符串,否则预处理阶段很容易出问题。
4. 旧式宏录制没了,自动化这摊事现在该怎么干
前面提到 VS2012 把宏录制功能移除了,但很多人还是希望做一些「自定义宏」式的操作自动化。ReSharper、C++ 等插件生态虽然繁荣,但真正要在 IDE 层面实现一个「一键操作」还是得靠几个官方路径。
4.1 用「外部工具」把参数当宏传
你的实际诉求可能是:把当前项目路径、当前文件名传给某个命令行程序,让它跑一个自定义处理。这在旧版 IDE 里很容易用宏实现,新版里最接近的替代是「外部工具」。
菜单路径:工具 -> 外部工具 -> 添加。配置一个工具,命令填你要跑的 exe 或脚本路径,参数里可以直接用$(ProjectDir)、$(ProjectFileName)、$(ItemPath)这些 IDE 提供的宏占位符。这些占位符是 IDE 在调用外部工具时实时替换的,和 MSBuild 属性宏不是一个体系,但思路很像。
我经常用这套组合:外部工具调用一个 Python 脚本,参数传$(ProjectDir),脚本自动扫描项目里的 TODO 注释并生成报告,绑定快捷键后一键执行,体验基本回到了当年宏录制时代。
4.2 用 VSIX 扩展承载自动化逻辑
如果你需要的不是「跑一次脚本」而是「常驻 IDE 的右键菜单动作」,最专业的方式是写一个 VSIX 扩展。在扩展代码里可以监听 IDE 事件,操作解决方案、项目、文档对象,自由度比外部工具高得多。但代价是学习曲线陡,需要了解 Visual Studio SDK 和 MEF 或 AsyncPackage。
对大多数人来说,VSIX 的职责是「把频繁做的多步手动操作用代码固化」。比如我一个同事写过一个扩展:右键项目一键添加所有源文件的版权头。这在老宏系统里只是一个几十行的小宏,现在用 VSIX 也就百来行代码。
4.3 T4 模板:代码生成场景下的「宏」
还有一类容易被忽略的东西——T4 文本模板(.tt 文件)。它可以在编译前生成代码,模板里能读取项目属性、环境变量和自定义参数,勉强算「模板级宏」。我用 T4 生成了不少自动化的枚举定义和配置类,避免了手写重复代码。这个方向展开又是一篇文章,这里提一句:如果你想实现的「宏」本质是「根据一套参数批量生成代码」,优先考虑 T4,而不是硬写一个代码生成器。
5. 实战案例:一套开关控制产品定制、版本号注入和条件日志
理论讲完,给一个完整的实战串联,照着做就能在项目里直接落地。
5.1 场景定义
假设我们有一个 C++ 桌面应用,要出三个版本:
Basic:不含高级模块Pro:含高级模块,但不含企业级接口- 内部调试版:包含所有功能,且输出详细日志
要求一次改动配置就能切版本,不能每次发布都去源码里翻#define改。
5.2 工程文件层面统一开关
在 .vcxproj 定义两组属性:
<PropertyGroup> <!-- 默认版本为 Basic --> <EditionName>Basic</EditionName> <FeatureAdvanced>false</FeatureAdvanced> <FeatureEnterprise>false</FeatureEnterprise> <VerboseLog>false</VerboseLog> </PropertyGroup> <PropertyGroup Condition="'$(EditionName)'=='Pro'"> <FeatureAdvanced>true</FeatureAdvanced> </PropertyGroup> <PropertyGroup Condition="'$(EditionName)'=='Enterprise'"> <FeatureAdvanced>true</FeatureAdvanced> <FeatureEnterprise>true</FeatureEnterprise> </PropertyGroup>然后在PreprocessorDefinitions里把开关映射成预处理宏:
<ItemDefinitionGroup> <ClCompile> <PreprocessorDefinitions> HAS_ADVANCED=$(FeatureAdvanced); HAS_ENTERPRISE=$(FeatureEnterprise); VERBOSE_LOG=$(VerboseLog); EDITION_NAME=$(EditionName); %(PreprocessorDefinitions) </PreprocessorDefinitions> </ClCompile> </ItemDefinitionGroup>注意HAS_ADVANCED=$(FeatureAdvanced)展开后是HAS_ADVANCED=false或HAS_ADVANCED=true。这里有个细节必须有意识地处理:C++ 预处理器里true和false是关键字,但在#if表达式中可以被当作常量使用,#if HAS_ADVANCED能正确工作。然而为了保险,我更推荐改用 0/1 写法,或者直接依赖「是否定义」判断。
5.3 源码里的条件分支
推荐版本(用是否定义判断):
#ifdef HAS_ADVANCED // 高级模块代码 #endif #ifdef HAS_ENTERPRISE // 企业接口代码 #endif #ifdef VERBOSE_LOG // 详细日志 #endif关键点:用#ifdef判断时,影响开关的是这个宏「是否参与了编译」,因此工程文件里不要写成HAS_ADVANCED=1这种带值形式,只需要写HAS_ADVANCED。改了预处理器定义里的写法,效果完全不同,这是新手最容易踩的坑之一。
5.4 版本号注入
版本号也可以由属性宏统一提供:
<PropertyGroup> <AppVersion>3.1.0</AppVersion> </PropertyGroup> <ClCompile> <PreprocessorDefinitions> APP_VERSION_STR=$(AppVersion); %(PreprocessorDefinitions) </PreprocessorDefinitions> </ClCompile>代码里把编译常量变成字符串字面量,需要一个经典的双层宏技巧,否则展开结果不对:
#define STR_HELPER(x) #x #define STR(x) STR_HELPER(x) const char* kVersion = STR(APP_VERSION_STR);这里如果只写一层:
const char* kVersion = STR(APP_VERSION_STR);假设STR定义为#define STR(x) #x,展开结果会是"APP_VERSION_STR"而不是"3.1.0"。因为#运算符只对它紧邻的参数做字符串化,如果那个参数本身是个宏,不会先展开。所以需要STR_HELPER这层中转,让APP_VERSION_STR先被展开成3.1.0,再由STR_HELPER字符串化。这个技巧我在不少开源项目里看到过,属于「你不踩一次坑就记不住」的典型。
5.5 切换版本的实际操作
发布不同版本时,我推荐不要在 VS 界面里手动改属性,而是用 MSBuild 命令行传入属性值:
msbuild MyApp.vcxproj /p:Configuration=Release /p:EditionName=Pro /p:VerboseLog=false命令行传入的属性值会覆盖工程文件里的默认值,而且不会污染工程文件本身。这样可以在 CI 流水线里为不同客户跑出不同二进制,源码仓库里不会残留一堆临时改动。这套玩法熟练掌握后,才是真正把「宏预处理」用到了点子上。
6. 踩坑记录:宏展开、继承覆盖、平台属性和命名合理
最后这部分全是实操里踩出来的经验,每个坑都对应一个真实的「排查数小时、修复一分钟」的事故。
6.1 字符串化参数不展开,需要中转宏
上面版本号例子已经讲了一半。再补充一个连接符号##的坑:
#define MERGE_IMPL(a, b) a##b #define MERGE(a, b) MERGE_IMPL(a, b) #define FEATURE_FLAG 1 int MERGE(feature, FEATURE_FLAG) = 0;同样,如果直接#define MERGE(a, b) a##b,当传入的某个参数本身是宏时,##连接前不会展开,结果会生成一个名为featureFEATURE_FLAG的变量。加一层中转,才能得到feature1。规则总结一句话:参数在#或##运算符附近时不会自动展开,需要多包一层宏触发展开。
6.2_DEBUG和NDEBUG同时存在
如果一个项目里既在 Release 配置下手动定义了_DEBUG,编译器又自动定义了NDEBUG,就会出现同行评审时最尴尬的局面:
#ifdef _DEBUG // 调试代码 #else // 发布代码 #endif明明编译的是 Release,却走了调试分支。根因是有人在 Release 的预处理器定义里手贱加了_DEBUG。排查思路是去「命令行」页面查看实际传给编译器的/D参数。所以我现在的原则是:以_DEBUG和NDEBUG为绝对的编译器内置管制宏,一般不动它们;所有业务开关一律自定义新宏名。
6.3 Win32 和 x64 属性独立,最容易漏配
属性页里「平台」下拉框的存在,导致很多人只在当前激活平台改了宏,换平台编译直接行为异常。遇到换个平台就出现功能对不上的情况,第一反应应该是:检查另一个平台下的预处理器定义是否和当前平台一致。
最稳妥的方案是,在所有ItemDefinitionGroup里统一用%(PreprocessorDefinitions)继承,并且把平台无关的宏放在All Configurations / All Platforms下,平台相关的差异宏再分别配置。
6.4 取消「从父级或项目默认值继承」的后果
前面提过%(PreprocessorDefinitions),在属性页编辑宏的对话框里,底部有个「从父级或项目默认值继承」的复选框。取消后,整个宏列表会被清空重来。很多人不了解这个勾选框的作用,为了让列表「干净」而取消它,结果编译器预置的_DEBUG、_UNICODE、WIN32或 SDK 注入的宏全部丢失,接着就是一连串的报错——比如NO_ERROR未定义、GetMessage宏消失。
这个坑排查起来有点迷惑,因为报错位置和定义丢失的位置往往离得远。确认方法:查看编译器命令行,对比正常项目和问题项目传的/D参数。所以我的建议是:平时直接编辑 .vcxproj 文件手动维护PreprocessorDefinitions,保留末尾的%(PreprocessorDefinitions),图形界面只是辅助预览。
6.5 不要覆盖 VS 内置属性宏
MSBuild 有很多内置属性宏,比如$(SolutionDir)、$(ProjectDir)、$(Configuration)、$(Platform)。有次我想把一个输出目录统一改到一个自定义路径,顺手在项目里加了个:
<PropertyGroup> <ProjectDir>D:\temp\output</ProjectDir> </PropertyGroup>结果导致整个项目的相对路径全部错乱,因为无数内置项依赖$(ProjectDir)这个宏指向项目文件所在目录。内置宏名是保留字,自定义宏永远不要和内置宏重名。如果你的需求确实要改输出目录,正确做法是自定义一个新属性名,再赋值给OutDir或TargetName。
6.6 宏命名的隐性规范
宏多了之后,命名不统一会在维护期要人命。总结我推荐的规矩:
- 项目业务开关统一前缀,比如
PROJ_,分组清晰。 - 宏名全大写,单词间下划线分隔。
- 有值宏用 0/1 或具体整数,不要用 true/false(避免部分场景下行为歧义)。
- 涉及向用户展示的宏名,尽量用不带下划线开头的形式,避免和编译器保留宏混淆。
- 每个宏在文档或 README 里有一行注释说明用途,否则三个月后你自己都记不住。
以我现在的习惯,会在工程的根目录放一个宏定义清单.md,按模块维护所有自定义宏的说明、默认值、影响范围。这个习惯帮我省了不知道多少分析时间,强烈推荐。
7. 写在最后:宏开关的维护是一门被低估的功课
「Visual Studio 自定义宏」这个话题看着小,实际上牵涉到了 IDE 自动化、构建系统和编译器预处理三个阶段。把三套机制分清楚,再把属性宏和预处理宏打通,整个工程的可配置性会上一个大台阶。
从项目维护的角度说,宏开关和配置文件一样,需要持续治理。我个人的经验是:新增一个宏之前,先问自己三件事——这个宏能不能用现有宏通过逻辑组合得到?它需要让源码感知还是只需要构建系统感知?如果两个模块都要用,是不是应该提到解决方案级共享配置而不是各自重复定义?
回答完这三个问题,大部分「宏加多了导致混乱」的局面都能避免。至少在我接手过的项目里,凡是宏定义集中管理、命名有规律、文档跟得上的,后续扩展功能时都非常顺畅;反之,凡是宏写得随心所欲的项目,代码里到处都是僵尸代码,看着就想重构。
如果你现在正被一堆散落的宏定义折磨,不妨先花半天时间,把项目里所有自定义宏列出来,画一张「宏 -> 影响范围 -> 默认值 -> 配置位置」的对应表,然后逐步收敛到工程文件的 PropertyGroup 里。这个投入,比以后再花两周排查一个莫名其妙的编译行为要划算得多。