写给第一次接触「跨平台」的仓颉学习者
从「为什么要跨平台」讲到亲手跑通一个例子
Cangjie · 入门 · 跨平台
这篇不假设你学过任何其他编程语言,也不假设你懂操作系统内部。
如果你刚学仓颉,听到「跨平台」「条件编译」「common/specific」这些词一头雾水——这篇就是给你的。我们从一个最朴素的问题开始:同一段代码,为什么不能在所有电脑上跑?
文中每个例子都可以自己敲一遍,命令和输出都是我在 Linux 上真跑出来的。
一、先搞清楚:什么叫「平台」
「平台」这个词听着虚,其实特别实。它指的是你这台电脑的底层环境,主要包含两件事:
- 操作系统(OS):Windows、Linux、macOS……每个系统的「规矩」都不一样。
- 处理器架构(CPU):常见的是
x86_64(Intel/AMD 的 64 位)和aarch64(ARM 的 64 位,手机、苹果 M 系列芯片都是它)。
举个大白话的例子:你想让程序「把一段数据写进磁盘文件」。
- 在 Linux 上,你调用一个叫
open的系统函数; - 在 Windows 上,你要调用
CreateFileW; - 名字不一样、参数也不一样。
这些函数不是仓颉发明的,是操作系统本身提供的。仓颉程序要想跟操作系统打交道,就得按那个系统的规矩来。
所以「跨平台」的意思就是:你写的代码,能不能在不止一个系统上正常工作。
为什么这件事会变成难题
假设你写了一个小工具,功能是「把数据写进文件」。它一开始只考虑 Linux:
// 只有 Linux 这么写才对
open("文件路径", ...)
现在你想让 Windows 用户也能用。Windows 上没有 open,只有 CreateFileW。于是你有两个选择:
- 写两份代码,一份 Linux 一份 Windows,让程序自己挑;
- 写一份代码,让它根据目标系统自动变成对应的样子。
仓颉选的是第二条路。而且它给了你三种不同粒度的工具。下面一个一个讲。
二、最简单的办法:@When 条件编译
「条件编译」四个字拆开看:按条件决定哪些代码参与编译。
先看一个完整的、能直接跑的例子:
@When[os == "Linux"]
func platformName(): String {
"Linux"
}
@When[os == "Windows"]
func platformName(): String {
"Windows"
}
@When[os == "macOS"]
func platformName(): String {
"macOS"
}
main() {
println("你现在用的系统是:${platformName()}")
}
把它存成 hello.cj,然后编译运行:
$ cjc hello.cj -o hello
$ ./hello
你现在用的系统是:Linux
在 Windows 上编译同一份文件,输出就会变成 Windows;在 Mac 上会变成 macOS。 你什么都没改,同一份源码,三个系统各跑各的。
它是怎么做到的
@When[os == "Linux"] 的意思是:「只有当目标系统是 Linux 时,下面这个函数才参与编译」。
于是在 Linux 上编译时,编译器眼里只有第一个 platformName;Windows 和 macOS 那两个根本不存在——它们在编译开始前就被排除了。
这是理解一切的钥匙:不是「运行时挑一个」,而是「编译时就把不需要的扔掉」。
所以不参与编译的代码,即使里面有当前系统不认识的函数名,也完全不会报错——因为它压根没被编译器看见。
写错了会怎样?报错特别直白
假设你写了一个「只有 Windows 才有」的函数,却在 Linux 上调用它:
@When[os == "Windows"]
func windowsOnly(): Unit {
println("这行只有 Windows 会编译")
}
main() {
windowsOnly()
}
在 Linux 上编译:
error: undeclared identifier 'windowsOnly'
意思是「我不认识 windowsOnly 这个名字」——因为在 Linux 上,这个函数根本没被编译进去。这个报错非常好懂,也是初学者最容易遇到的:你以为你写了,其实在你当前的系统上它不存在。
@When 里能写什么条件
| 条件 | 能填的值 | 说明 |
|---|---|---|
os |
"Windows" / "Linux" / "macOS" |
目标操作系统 |
arch |
"x86_64" / "aarch64" |
目标处理器架构 |
backend |
"cjnative" / "cjvm" |
后端类型 |
debug |
不填值,只能写 !debug |
是否开了 -g 调试模式 |
test |
不填值,只能写 !test |
是否在跑单元测试 |
还能用 != 取反:
@When[os != "Windows"]
func notForWindows(): Unit {
println("除了 Windows 都能编译这行")
}
两个新手容易踩的地方
第一,@When 不能连着叠两层:
@When[os == "Windows"]
@When[os == "Linux"] // ❌ 报错:duplicated annotation: '@When'
func foo() {}
想同时满足两个条件,应该用 && 写到一起(实测可以正常编译):
@When[os == "Linux" && arch == "x86_64"]
func foo() {}
第二,os == 后面的值必须首字母大写。 "Linux" 对,"linux" 不对。
顺便记住一个对比:后面讲的
cjpm配置里,平台名是小写点分的os.linux。一个是"Linux",一个是os.linux,别写混。这个坑我下面还会再提一次。
三、麻烦来了:每个函数都要标一遍
@When 很好懂,但它有个明显的缺点:它挂在每个函数(或每个声明)上。
写三五个函数还好,可如果你在写一个「只有 Linux 才有的完整功能模块」,里面有几十个函数、几十个常量呢?
@When[os == "Linux"]
foreign func mmap(...): CPointer<Unit>
@When[os == "Linux"]
foreign func munmap(...): Int32
@When[os == "Linux"]
foreign func msync(...): Int32
@When[os == "Linux"]
private const PROT_READ: Int32 = 0x1
// ……还有二十个
每一个都得单独标注一遍。 这就是后面要讲的真实项目里发生过的事——它最终就是这么写的,一个文件里标了 8 遍。
这时候你会想要一个更省事的办法:「能不能直接说『这个文件属于 Linux』,一次性搞定?」
能。这就是第二种工具。
四、按目录分:source-set + features
先说清楚一件事:普通 .cj 文件,你直接 cjc 文件名.cj 就能编译(就像第二节那样)。下面这套要用 cjpm——仓颉的项目管理工具,它管的是一个完整的项目,不是一个文件。
cjpm 的思路是:用目录把代码分开,再告诉它「哪个目录属于哪个平台」。
4.1 项目长这样
cjdemo/
├── cjpm.toml ← 项目配置,告诉 cjpm 目录和平台的对应关系
├── common/ ← 所有平台共用的代码
│ └── platform.cj
├── linux/ ← 只有 Linux 用的代码
│ └── linux.cj
└── windows/ ← 只有 Windows 用的代码
└── windows.cj
4.2 三份代码分别写什么
common/platform.cj——公共部分,只写「有什么」,不写「怎么做」:
@NonProduct
features {}
package cjdemo
public common func Platform(): String
linux/linux.cj——Linux 的具体做法:
features {os.linux}
package cjdemo
public specific func Platform(): String { "Linux" }
main() {
println(Platform())
}
windows/windows.cj——Windows 的具体做法:
features {os.windows}
package cjdemo
public specific func Platform(): String { "Windows" }
main() {
println(Platform())
}
4.3 配置 cjpm.toml
[profile]
experimental = true
[package]
cjc-version = "1.2.0" # 必填,漏了会报 can't be empty
name = "cjdemo"
output-type = "executable"
version = "1.0.0"
[[source-set]]
name = "common"
src-dir = "./common"
features = []
[[source-set]]
src-dir = "./linux"
features = ["os.linux"]
product = true
[[source-set]]
src-dir = "./windows"
features = ["os.windows"]
product = true
4.4 编译运行
$ cjpm build --no-feature-deduce --enable-features=os.linux
cjpm build success
$ ./target/release/bin/linux/product/main
Linux
$ cjpm build --no-feature-deduce --enable-features=os.windows
cjpm build success
$ ./target/release/bin/windows/product/main
Windows
同一台 Linux 电脑上,两个产物分别打印出 Linux 和 Windows。
⚠️ 注意:这不是在 Linux 上编译出 Windows 程序。两个
main都是 Linux 程序,只是编译时选进了不同的代码目录,所以打印的内容不同。真正的交叉编译要用--target。
4.5 三个新概念,用大白话解释
features(特性)——一个「开关的名字」。os.linux 就是「Linux 这个开关」。
仓颉内置了这些:
os.android os.ohos os.ios os.linux os.windows os.darwin
source-set(源码集)——「一组源码文件所在的目录」。你在 cjpm.toml 里说「./linux 这个目录,对应 os.linux 开关」,编译时打开 os.linux,这个目录就会被编进去。
文件头的 features {}——每个源码文件自己也要声明属于哪个开关。必须写在 package 上面:
features {os.linux} // ← 必须在 package 之前
package cjdemo
公共目录的文件写空的 features {},并且要加一个 @NonProduct 标记:
@NonProduct
features {}
package cjdemo
4.6 我踩过的坑(照着抄能省半小时)
这些都是我实测撞出来的,写错一个就跑不起来:
| 你可能会犯的错 | 报错长什么样 | 怎么回事 |
|---|---|---|
公共文件忘了 @NonProduct |
Feature header of package 'cjdemo' is missing '@NonProduct' annotation |
公共源码集要标这个 |
两个目录写了相同的 features |
source set 'app' with the same features is declared at ... |
一个开关组合只能给一个目录 |
main 写在公共目录里 |
'main' is missing |
main 必须放在平台目录里 |
| 公共文件里写了没有函数体的函数 | body of function 'Platform' is missing |
光写签名不行,得加 common |
最后一条是重点,下一节专门讲。
五、common 和 specific:一个负责「说」,一个负责「做」
5.1 它们解决什么问题
看上面 common/platform.cj 里的这一行:
public common func Platform(): String
注意:它没有函数体——只有名字和类型,没有大括号,没写「怎么做」。
普通函数这样写是错误的(编译器会说 body of function is missing,函数体缺失)。但加上 common 就合法了,因为它的意思是:
「本包一定要有一个叫
Platform、返回String的函数。具体怎么做,看是哪个平台。」
它是一份约定(也叫「契约」)。common = 公共的、要说的。
然后在每个平台目录里,用 specific 给出具体做法:
public specific func Platform(): String { "Linux" }
specific = 特定平台的、实际做的。
这就是 common 和 specific 的分工:
| 关键字 | 在哪 | 干什么 |
|---|---|---|
common |
公共部分代码 | 说:这个接口存在,长什么样 |
specific |
平台部分代码 | 做:这个平台的具体实现 |
5.1.1 用你熟悉的 interface 来理解
上面说的是「契约」,这个词有点抽象。仓颉本身就有一个表达「契约」的东西:interface(接口)——common 的思路和它是一回事。
先看接口是怎么用的:
interface Speaker {
func speak(): String // 只规定「要有这个方法」,不写怎么做
}
class Dog <: Speaker {
public func speak(): String { "汪" } // 具体怎么做,由 Dog 说了算
}
interface 说「必须有什么」,class 用 <: 说「我来实现」。common 和 specific 就是这个关系的跨平台版本:
| 普通接口写法 | 跨平台写法 | |
|---|---|---|
| 规定「有什么」 | interface Speaker { func speak(): String } |
public common func Platform(): String |
| 给出具体实现 | class Dog <: Speaker { func speak(): String } |
public specific func Platform(): String { "Linux" } |
| 谁负责实现 | 一个类 | 每个平台各写一份 |
| 编译器检查什么 | 实现了接口的方法没有、签名对不对 | 每个平台都有对应实现、签名对不对 |
两者可以叠加使用,我实测过这段是合法且能跑的:
// 公共部分
public interface Speaker {
func speak(): String
}
public common class Dog <: Speaker {
public init() {}
public common func speak(): String // 这个类在公共部分只立契约
}
// Linux 平台部分
public specific class Dog <: Speaker {
public specific func speak(): String { "汪" }
}
运行输出 汪。
一句话记住差别:
interface管的是「同一份代码内,谁能被当成谁用」;common/specific管的是「跨平台,这个功能在每个系统上长什么样」。
所以你可以这样理解:common 像一份「必须各平台都实现的接口」,specific 就是各平台各自的实现。 区别在于——接口的实现在编译时是定好的,而 specific 的实现由你这次编译的目标平台决定。
这也解释了一个初学者常有的疑惑:为什么
common声明的函数可以不写函数体?因为它的角色就是「接口」,本来就不该有实现。
5.2 一句话例子
// 公共部分:只说不做
package cmp
public common func foo(): Unit {
println("I am common")
}
// 平台部分:真正做
package cmp
public specific func foo(): Unit {
println("I am specific")
}
(两个文件都是 package cmp——它们属于同一个包,只是放在不同目录。)
5.3 三条最重要的规矩
common已经写了完整实现,就可以不写specific;common只写了签名(没有函数体),就必须有specific。specific的样子必须和common对得上:参数类型一样、返回值一样(或是它的子类型)、其他修饰符一样。- 一个
specific只能对应一个common,不能两个平台函数去对应同一个common。
这些规矩编译器会严格检查。写错了会直接报错:
error: 'common' function return type must be specified
error: 'specific' function 'bar' can not find 'common' match
error: 'common' function 'foo' can not find 'specific' match
第二、三条的意思是「你写了个平台实现,但找不到对应的约定」和「你立了个约定,但没人实现」。这种错误在拼错名字、漏写实现时特别常见,看到就知道是配对没对上。
5.4 什么时候用哪个
| 你的情况 | 用哪个 |
|---|---|
| 一个函数在不同系统实现不同 | common + specific |
| 一整个目录都是某个系统专用 | source-set + features |
| 只有几个地方要区分系统 | 直接 @When |
| 既要统一接口、又要分平台 | common/specific 配合 source-set |
重要:source-set 和 common/specific 不是二选一,而是配合的——
source-set管「这次编译,哪些目录参与」;common/specific管「公共部分怎么立约定」。
第四节的例子里,公共文件那行 public common func Platform(): String 两个都用上了:common 让它合法,source-set 让它只被编译一次。
六、一个真实的项目:mmap-cj
讲了这么多机制,来看一个真实项目怎么选的。
mmap-cj
是一个「内存映射文件」库——简单说,就是让程序像读写内存一样读写磁盘文件,速度很快。它要同时支持 Linux、macOS、Windows,而这三个系统的底层函数完全不同。
它的开发过程很有意思,换了两次方案:
2026-04-11 第一次:common/specific + source-set 目录布局
2026-07-17 重构目录结构
2026-10-06 第二次:切回 @When 条件编译 ← 转折点
为什么一开始用 source-set,后来又切回麻烦的 @When?
作者在提交记录里写得很清楚:
source-set是cjpm的实验特性:使用者(也就是依赖这个库的项目)必须同样声明experimental = true才能编译本库,否则报错。
我实际验证了一遍。假设有个项目要使用这个库(不开实验开关):
$ cjpm build
Error: path of src directory 'src' in .../mmap-cj/cjpm.toml does not exist
Error: cjpm build failed
连编译都过不去,测试更跑不了:
$ cjpm test
Error: path of src directory 'src' in .../mmap-cj/cjpm.toml does not exist
Error: cjpm test failed
使用方必须在自己项目里加上这一行才行:
[profile]
experimental = true
加了开关,还有一个更麻烦的问题
按理说,加上那一行就该好了。但我实测发现:build 能过了,test 还是过不去。
$ cjpm build
cjpm build success # ← 好了
$ cjpm test
Error: cjpm test failed # ← 还是挂
而且这个报错很不好查——它只回一句 cjpm test failed,看不出原因。深挖进去才发现,是链接测试程序时漏掉了这个库(报了一堆 undefined reference)。
这就不只是「多写一行配置」的问题了:只要用了 source-set 布局的库,这个项目的单元测试就等于没法跑。
这个问题已经上报并修好了
发现这个问题后,它被整理成 issue 报给了仓颉官方:
- issue #3515:cjpm test 无法解析
[[source-set]]依赖 - 修复 PR #1311 :给测试流程补上依赖库的路径和链接
修好之后,这个问题就不存在了。 但在修复发布之前,mmap-cj 得先能用——它的使用者 badger-cj 有几百个测试等着跑。
这就是它切回 @When 的真正原因:@When 是普通功能,不需要开实验开关,build 和 test 都正常。换成 @When 版之后,使用方什么都不用改:
$ cjpm build
cjpm build success
ok: @When 版库,消费者不开 experimental 也能构建
顺带一提,这个影响是真实的——另一个项目 badger-cj 就依赖 mmap-cj,它的配置文件里明确写着:
[profile]
# 依赖 mmap-cj 使用 experimental 的 [[source-set]] 多平台布局,消费侧需同样开启
experimental = true
一个库用了实验特性,逼着所有用它的项目一起打开实验开关。 对一个要给别人用的库来说,这个代价太大了。
代价是什么
切回 @When 之后,代价就是第三节说的那个:每个声明都要标一遍。mmap-cj 现在是这样:
| 文件 | 行数 | @When 出现次数 |
|---|---|---|
src/mmap_linux.cj |
178 | 8 |
src/mmap_darwin.cj |
180 | 8 |
src/mmap_windows.cj |
250 | 8 |
src/mmap_test.cj |
235 | 4 |
这就是真实的取舍:
source-set写起来干净整齐,但它带着「实验特性」这个副作用,会影响所有使用者;@When啰嗦重复,但它是普通功能,谁用都没负担。
对一个要发布给别人用的库,作者最终选了后者。
补充一句公道话:
mmap-cj自己的测试,两套方案都是 6 个用例全过。问题不在库自己能不能测,而在别人依赖它之后能不能测——后者才是库要负责的事。
七、总结一下
三种工具,三种粒度
| 工具 | 管多大范围 | 一句话理解 |
|---|---|---|
@When |
一个函数 / 一条 import | 「这行代码只在某平台编译」 |
source-set |
整个目录 | 「这个目录只在某平台编译」 |
common/specific |
一个声明 | 「这个接口各平台都有,实现各自不同」 |
给初学者的建议
- 先学
@When。它最简单、最不容易出错,而且不需要实验开关,是目前最实用的。 @When里写"Linux"(大写),cjpm配置里写os.linux(小写)。这是我提醒第三次了,因为它们真的很像。- 写给别人用的库,慎用
source-set——它会让使用者也得开experimental。 common/specific是「约定 + 实现」,不是 Go 里那种「整个文件绑定平台」。它更适合「我要保证所有平台都有这个接口」的场景。- 遇到「函数体缺失」的报错,想想是不是漏了
common;遇到「找不到匹配」,想想是不是名字或类型写错了。
最后
「跨平台」听起来是很高级的话题,但核心就一句话:
有些代码只有某个系统能懂,所以要按系统把它们分开——而分开的粒度,有大有小。
仓颉给了你三把不同大小的刀。知道有哪三把、各自多大,就已经超过大多数人了。
附:文中命令速查
# 编译单个文件(试试第二节的例子)
cjc hello.cj -o hello
./hello
# cjpm 项目:按平台编译(第四节的例子)
cjpm build --no-feature-deduce --enable-features=os.linux
cjpm build --no-feature-deduce --enable-features=os.windows
本文所有输出均为
cjc 1.2.0/cjpm 1.2.0在 x86_64 Linux 上实际运行结果。
其中的source-set与common/specific自 1.1.0 起提供,目前是实验特性;@When是常规功能,可以直接用。