golang的条件编译

莱莱  论坛元老 | 2025-3-31 09:58:16 | 显示全部楼层 | 阅读模式
打印 上一主题 下一主题

主题 1977|帖子 1977|积分 5931

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

x
写c/c++或者rust的开辟者应该对条件编译不生疏,条件编译顾名思义就是在编译时让代码中的一部门生效或者失效,从而控制编译时的代码实行路径,进而影响编译出来的步伐的举动。
这有啥用呢?通常在编写跨平台代码的时间有用。比如我想开辟一个文件操作库,这个库有全平台统一的接口,然而各大操作体系提供的文件和文件体系api百花齐放,我们没法只用一套代码就让我们的库能在所有的操作体系上正常运行。
这时间就需要条件编译进场了,在Linux上我们只让适配了Linux的代码生效,在Windows上则只让Windows相关的代码生效其他失效。比如:
  1. #ifdef _Windows
  2. typedef HFILE file_handle
  3. #else
  4. typedef int file_handle
  5. #endif
  6. file_handle open_file(const char *path)
  7. {
  8.     if (!path) {
  9. #ifdef _Windows
  10.         return invalid_handle;
  11. #else
  12.         return -1;
  13. #endif
  14.     }
  15. #ifdef _Windows
  16.     OFSTRUCT buffer;
  17.     return OpenFile(path, &buffer, OF_READ);
  18. #else
  19.     return open(path, O_RDONLY|O_CLOEXEC);
  20. #endif
  21. }
复制代码
在这个例子里,Windows和Linux的api完全差别,为了潜伏这种差别我们用条件编译在差别平台上定义出了一组相同的接口,这样我们就无需关心平台差异了。
从上面的例子也可以看出,c/c++实现条件编译最常用的是依靠宏。通过在编译时指定特定平台的标识,这些预编译宏就能自动把不需要的代码剔除不进行编译。c和c++中另一种实现条件编译的做法是依赖构建体系,我们不再使用预编译宏,但会为每个平台都编写一份代码:
  1. // open_file_windows.c
  2. typedef HFILE file_handle
  3. file_handle open_file(const char *path)
  4. {
  5.     if (!path) {
  6.         return invalid_handle;
  7.     }
  8.     OFSTRUCT buffer;
  9.     return OpenFile(path, &buffer, OF_READ);
  10. }
  11. // open_file_linux.c
  12. typedef int file_handle
  13. file_handle open_file(const char *path)
  14. {
  15.     if (!path) {
  16.         return -1;
  17.     }
  18.     return open(path, O_RDONLY|O_CLOEXEC);
  19. }
复制代码
然后指定构建体系在编译Linux步伐时只使用open_file_linux.c,在Windows上则只使用open_file_windows.c。这样同样可以把和当前平台无关的不兼容的代码清除掉。现在的构建体系如meson,cmake都可以轻松实现上述功能。
自称体系级的golang,自然也是支持条件编译的,而且它支持的方式是靠第二种——即依靠构建体系。
想要在golang中使用条件编译,也有两种办法。因为我们不使用宏,也没法在编译时给go build指定信息哪些代码不需要,以是需要一些手段来让go编译工具链识别出应该编译和应该忽略的代码。
第一种就是依赖文件后缀名。go的源代码文件的名字是有特殊规定的,符合下面格式的文件会被以为是在特定平台上需要被编译的文件:
  1. name_{system}_{arch}.go
  2. name_{system}_{arch}_test.go
复制代码
此中system的取值和环境变量GOOS一样,常见的有windows、linux、darwin、unix,此中后缀是unix时文件会在Linux、bsd和darwin这些平台上编译。没有明确指定那么该文件就会在全平台有用,除非有额外指定我们后面会说的build tag。
arch的取值和GOARCH环境变量一样,都是常见的硬件平台比如amd64、arm64、loong64等等。有这些后缀的文件只会在为特定的硬件平台编译步伐时才会生效并加入编译过程。假如没明确指定arch,则默认目标操作体系的所有支持的硬件平台上这个文件都会到场编译。
第一种方法简朴易懂,但缺点也很明显,我们需要为每个平台都维护一份源代码文件,而且这些文件里必定会有很多重复的平台无关的代码,这对维护来说是个很大的负担。
以是第一种方案只适合那种平台间差异巨大的代码,一个典型的例子是go自己的runtime的代码,因为协程调度需要很多操作体系甚至硬件平台的功能做辅助,因此runtime在每个操作体系上出了自己的api之外差异很大,因此使用文件名后缀的形式分成多个文件维护是比较符合的。
第二种方法不再使用文件名后缀,而是依赖build tag这种东西来提示编译器哪些代码需要被编译。
build tag是go的一种编译指令,用于告诉编译器该文件需要在什么条件下才需要被编译:
  1. //go:build 表达式
复制代码
tag一般写在文件的开头(在版权声明之后)。此中表达式是一些tag的名字和简朴的布尔运算符。比如:
  1. //go:build !windows
  2. 表示文件在Windows以外的系统上才编译
  3. //go:build linux && (arm64 || amd64)
  4. 表示在arm64或者amd64的Linux系统上才编译这个文件
  5. //go:build ignore
  6. 特殊tag,表示文件不管在什么平台上都会被忽略,除非明确使用go run、go build或者go generate运行这个文件
  7. //go:build 自定义tag名
  8. 表示只有在`go build -tags tag名`明确指定相同的tag名时才编译这个文件
复制代码
预定义的tag的值实在就是前面文件名后缀那里提到过的system和arch。可以看到逻辑运算符和括号都可以使用,语义也和逻辑运算一样。使用tag的长处在于它可以让linux和Windows通用的逻辑出现在同一个文件里而不需要复制两份到_windows.go和_linux.go里。更重要的是它答应我们自定义编译tag。
能自定义tag的话玩法就很多了,我们来看个例子,一个可以在编译时指定日志输出级别的玩具步伐,它的特点在于低于指定级别的日志不仅不会输出,而且连代码都不会存在,真正的做到零开销。
通常控制日志输出级别都是这么做的:
  1. func DebugLog(msg ...any) {
  2.     if level > DEBUG {
  3.         return
  4.     }
  5.     ...
  6. }
  7. func InfoLog(msg ...any) {
  8.     if level > INFO {
  9.         return
  10.     }
  11.     ...
  12. }
复制代码
然而这不可避免的需要一次if判断,假如函数比较复杂的话还需要付出一次额外的函数调用开销。
使用条件编译可以消除这些开销,首先是处置惩罚debug级别的日志函数:
  1. // file log_debug.go
  2. //go:build debug || (!info && !warning)
  3. package log
  4. import "fmt"
  5. func Debug(msg any) {
  6.     fmt.Println("DEBUG:", msg)
  7. }
  8. // file log_no_debug.go
  9. //go:build info || warning
  10. package log
  11. func Debug(_ any) {}
复制代码
作为最低的级别,只有在指定了debug这个tag以及默认情况下才会生效,其他时间都是空函数。
info级别的处置惩罚是一样的,只有指定级别为debug和info时才生效:
  1. // file log_info.go
  2. //go:build !debug && !warning
  3. package log
  4. import "fmt"
  5. func Info(msg any) {
  6.         fmt.Println("INFO:", msg)
  7. }
  8. // file log_no_info.go
  9. //go:build warning
  10. package log
  11. func Info(_ any) {}
复制代码
末了是warning级别,这个级别的日志不管在什么时间都会输出,因此它不需要条件编译以是也不需要tag:
  1. // file log_warning.go
  2. package log
  3. import "fmt"
  4. func Warning(msg any) {
  5.         fmt.Println("WARN:", msg)
  6. }
复制代码
末了是main函数:
  1. package main
  2. import "conditionalcompile/log"
  3. func main() {
  4.         log.Debug("A debug level message")
  5.         log.Info("A info level message")
  6.         log.Warning("A warning level message")
  7. }
复制代码
因为我们把不生效的函数都写成了空函数,因此编译器会在编译时发现这些空函数的调用什么都没做,因此直接忽略掉它们,以是运行的时间不会产生任何额外的开销。
下面简朴做个测试:
  1. $ go run
  2. # 输出
  3. DEBUG: A debug level message
  4. INFO: A info level message
  5. WARN: A warning level message
  6. $ go run -tags info .
  7. # 输出
  8. INFO: A info level message
  9. WARN: A warning level message
  10. $ go run -tags warning .
  11. # 输出
  12. WARN: A warning level message
复制代码
和我们预期的一致。不过我并不推荐你使用这个方法,因为它需要为每个日志函数编写两份代码,而且需要对编译tag做很复杂的逻辑运算,非常轻易出错;而且运行时一次if判断一般也不会带来太多的性能开销,除非明确定位到了判断日志级别产生了不可接受的性能瓶颈,否则永远不要实验使用上面的玩具代码。
不过生产实践里真的有使用自定义tag的例子:wire。
依赖注入工具wire让开辟者把需要注入的依赖写入有特殊编译tag的源文件,这些源文件正常编译的时间不会被编译到步伐里,使用wire工具生成注入代码的时间这些文件才会被识别,这样既可以正常实现依赖注入功能又不会对代码产生太大的影响。更具体的做法可以去看wire的使用教程。
至于选择在golang里选择哪种方式实现条件编译,这个得团结实际需求来看。至少像go自身的代码以及k8s中两种方法文件名后缀和build tag都有并行使用,最重要的选择依据还是要以方便自己和他人维护代码为准。

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

莱莱

论坛元老
这个人很懒什么都没写!
快速回复 返回顶部 返回列表