写给第一次接触「跨平台」的仓颉学习者

从「为什么要跨平台」讲到亲手跑通一个例子

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。于是你有两个选择:

  1. 写两份代码,一份 Linux 一份 Windows,让程序自己挑;
  2. 写一份代码,让它根据目标系统自动变成对应的样子。

仓颉选的是第二条路。而且它给了你三种不同粒度的工具。下面一个一个讲。


二、最简单的办法:@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 三条最重要的规矩

  1. common 已经写了完整实现,就可以不写 specific;common 只写了签名(没有函数体),就必须有 specific。
  2. specific 的样子必须和 common 对得上:参数类型一样、返回值一样(或是它的子类型)、其他修饰符一样。
  3. 一个 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 报给了仓颉官方:

修好之后,这个问题就不存在了。 但在修复发布之前,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 一个声明 「这个接口各平台都有,实现各自不同」

给初学者的建议

  1. 先学 @When。它最简单、最不容易出错,而且不需要实验开关,是目前最实用的。
  2. @When 里写 "Linux"(大写),cjpm 配置里写 os.linux(小写)。这是我提醒第三次了,因为它们真的很像。
  3. 写给别人用的库,慎用 source-set——它会让使用者也得开 experimental。
  4. common/specific 是「约定 + 实现」,不是 Go 里那种「整个文件绑定平台」。它更适合「我要保证所有平台都有这个接口」的场景。
  5. 遇到「函数体缺失」的报错,想想是不是漏了 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 是常规功能,可以直接用。