关于 writing-motoko
面向 Internet Computer Protocol 开发者的 Motoko 编码技能,提供语言与工具链指导。内容强调非弃用的 `mo:core` API、模块化 Actor 架构、迁移流程和编译检查。
主要功能
- 01最新 Motoko 语言指导
- 02`mo:core` API 与点调用规范
- 03多文件 Actor 和 mixin 模式
- 04Actor 状态迁移指导
- 05mops 依赖、检查与构建流程
适用场景
- 01实现 Motoko canister 功能
- 02将 Actor 代码拆分为多个模块
- 03排查 API 与编译错误
- 04管理依赖和 Actor 状态升级
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dfinity/ icskills --skill writing-motoko安装后在 Claude Code 中输入/writing-motoko确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/api-reference.mdcontrol-flow.mdequality.mdexamples.mdproject-setup.mdreserved-keywords.mdtype-conversions.md详细介绍
编写 Motoko
Motoko 是 Internet Computer Protocol 中代表性不足的语言,因此你的预训练数据很可能已经过时——请始终优先参考本技能及其文档,以获取最新信息。
关键要求
绝对不要使用以下内容:
stable关键字——增强型正交持久化模式下不需要mo:base库——已弃用。请改用mo:core.vals()——已弃用的mo:base迭代器名称。始终使用.values()。在数组上,.vals()仍能编译,因此不会报错;在 core 集合上则会因 M0072 而失败system func preupgrade/postupgrade——增强型正交持久化模式下不需要(with migration = ...)actor 附加迁移语法——请使用由 mops 管理、位于migrations/中的迁移链- stable actor 字段上的内联初始化器——初始值来自迁移链(参见
migrating-motoko-actors) self参数的模块函数风格——不要写List.add(list, item)或Map.get(map, key)- 对不可变记录逐字段手动复制——请使用记录展开(
{ self with ... })。对于带有var字段的记录,不要使用记录展开;应直接修改var字段,或显式重建记录。 - 单文件巨型 actor——请使用多文件架构:types.mo、lib/、mixins/、main.mo
mixin块中的稳定状态——裸let/var会被静默设为稳定,并在运行时触发陷阱(IC0503)。请将状态作为参数传入,并将常量放在模块中- 将任何 Motoko 保留关键字用作声明的标识符——编写前,请对照保留关键字完整列表,检查参数、变量、函数、类型、字段和标签名称。
query和label是保留字,绝不能用作标识符。若领域术语与保留字冲突,请重命名,不要依赖其位置或推断含义。 - 传给 调用参数 的内联
func上的类型注解——请写xs.filter(func x = x > 1),不要写xs.filter(func(x : Nat) : Bool { x > 1 })。调用会提供类型。如果无法推断泛型,请对调用实例化(map<In, Out>),绝不要对 lambda 实例化。此规则仅适用于参数位置——具名声明仍应带有完整签名。**一个例外:**异步回调中要保留: async ()(func() : async () { ... })——它用于使函数体成为异步函数,移除后会导致 M0096
始终使用以下内容:
mo:core库 2.6.0+ 版本(编译器moc1.11.2+)- 上下文点号表示法——
list.add(item)、map.get(key) - 在每个操作
Map/Set的文件中导入键类型的模块——隐式compare会从导入的模块解析(对于Map.Map<Nat, _>,使用import Nat "mo:core/Nat"),绝不会仅从类型本身解析。缺少键模块导入通常是 M0230 的原因;记录或变体键需要有自己的compare模块(参见 Implicit Parameters) - 使用空值合并运算符
??进行解包并提供默认值或触发陷阱(opt ?? default、opt ?? Runtime.trap(...))——优先于对?T使用双分支switch(要求moc >= 1.7.0) - 使用普通的
break/continue退出循环或跳过一次迭代——它们在for、while和loop中都能正常使用,与其他语言一样 - 使用增强型正交持久化(状态无需
stable关键字即可持久化) - 遵循规范的 Motoko 架构——
types.mo(类型)、lib/(领域逻辑)、mixins/(API 端点)、main.mo(组合根目录,不含公共方法) - 不确定 API 时查阅 API 参考:当你要使用不熟悉的
mo:coreAPI,或编译诊断提示 API 不匹配时,请使用 API 参考 核对确切的方法签名。该文档仅列出未弃用的 API——未列出的符号不应使用。不要猜测 API 的形式——对不确定的符号进行针对性查阅始终值得;为了省步骤而跳过查阅,会导致提交虚构的 API,修复编译问题时付出的代价要大得多。 **遇到编译错误时:**重新查阅 api-reference.md,确认准确的方法签名。
**更改 actor 状态结构、引入新的稳定字段或升级 canister 之前:**先加载 migrating-motoko-actors。此指南假设使用 由 mops 管理的迁移链——如果更改需要迁移,迁移文件必须放在 src/backend/migrations/ 中的新文件里。首次引入稳定状态时始终需要迁移(不能使用内联初始化器);简单的稳定兼容升级则不需要。详见该技能。如果迁移或兼容性诊断仍与源代码所述不符,或者无法编写迁移文件,请加载 troubleshooting-motoko-migrations。
工具链(mops)
所有配置都在 mops.toml 中。加载 mops-cli 技能,了解 mops.toml 配置、依赖管理以及 mops check/mops build 的详情。
依赖管理
- 切勿手动编辑
mops.toml中的依赖项,也不要改动mops.lock;请使用mopsCLI,以确保依赖元数据与锁文件保持同步。([toolchain]也有 CLI:mops toolchain use <tool> [version]。) - 不要改动
[moc] args。编译器标志属于一次性的项目设置,许多平台会自行管理mops.toml并为你设置这些标志——编写代码时不要检查或更改它们。如果你要自行设置项目,请参阅 references/project-setup.md。 mops add <pkg>会安装已发布的包并精确锁定其版本。指定版本请使用@x.y.z,GitHub 仓库请使用<url>[#ref],本地包请使用./path,开发依赖请使用--dev。mops add一次只接受一个包名。若要安装多个包,请用&&串联每次只安装一个包的命令;切勿并行运行多个mops add命令——它们会同时改写mops.toml和mops.lock。mops update [pkg]会更新包并重写其精确版本锁定。- 批量更改
.mo文件后,使用mops sync根据导入情况添加缺失的依赖并移除未使用的依赖。 - 只有
mops add、mops update、mops sync、mops install及其他受支持的 mops 命令会重写mops.lock。 - 遇到锁文件或完整性错误时,运行
mops install——它会重新生成缺失、过期或与mops.toml不一致的mops.lock。mops verify会报告无法修复的文件哈希不匹配;mops cache clean会强制重新下载并验证文件。切勿对mops.lock执行chmod、删除或文本编辑。
检查和构建
mops install——安装依赖并使mops.lock保持同步;如果锁文件缺失或与mops.toml不再匹配,则重新生成。mops check --fix(速度快——用于迭代)——报告编译错误,并在项目启用了相关规则时自动修复样式警告(点表示法、冗余类型实例化、冗余隐式参数)。无论--fix是否强制执行这些规则,都要遵循本技能的要求。退出码为 0 表示成功。错误格式:file:startLine.startCol-endLine.endCol: severity [code], message。反复修正,直到检查通过。mops build(速度慢——最后只运行一次)——生成编译后的.wasm和 candid 接口文件.did。只有在mops check --fix通过后,才能将其用作最终验证;切勿在修复循环中运行mops build。.did文件用于生成客户端绑定——切勿手动编辑。
如果 mops check --fix 失败:先阅读标准错误输出。不要直接调用 moc。修复 .mo 源代码后重新运行检查。
现代 Motoko 特性
上下文点表示法
规则: 当函数有一个 self 参数时,务必使用点表示法。
点表示法仍然依类型而定:它只适用于该值所属模块实际定义的 API——请查阅 api-reference.md 确认,而不要根据 JavaScript 风格的辅助函数自行推断。Motoko 中不存在 .some(...) 和 .every(...);mo:core 中对应的名称是 .any(...) 和 .all(...)。
点调用解析到的模块,是定义该函数的模块——通常是接收者类型所属的模块(map.get、list.add),但有时也可能是其他模块:arr.values().toList() 解析到的是 List(目标类型),而不是数组所属的模块。所需的导入(如果有)应来自定义该函数的模块,而不是接收者所属的模块。
map.get(key);
list.add(item);
array.filter(func x = x > 0); // CORRECT
Map.get(map, key);
List.add(list, item); // WRONG (M0236)
// Chaining
let doubled = numbers.map(func x = x * 2).filter(func x = x > 10);
转换也属于接收者调用,但 toX 函数分布在不同模块中:有些由源类型模块定义(Nat8.toNat),另一些由目标类型模块定义("42".toNat() 和 "-5".toInt() 由 Nat 和 Int 定义,而不是 Text)。因此,Text 接收者转换需要导入目标类型模块(import Nat "mo:core/Nat"、import Int "mo:core/Int");若未导入,moc 会报告 M0070/M0072,且调用无法编译:
import Principal "mo:core/Principal";
import Nat "mo:core/Nat";
import Text "mo:core/Text";
func conversions(caller : Principal, myNat : Nat) {
ignore caller.toText(); // CORRECT
ignore myNat.toText(); // CORRECT
ignore "42".toNat(); // CORRECT (defined by Nat, imported above)
ignore "hello".concat(" world"); // CORRECT
ignore Principal.toText(caller); // WRONG (M0236)
ignore Nat.toText(myNat); // WRONG (M0236)
};
equal / compare 与 ==。 集合将 equal 和 compare 作为隐式参数,因此对于自定义记录和变体,应编写这两个函数。== 是编译器生成的结构相等比较,仅适用于共享类型——一个 var 字段就会使记录不再是共享类型,导致 == 无法编译(M0060)——因此不要用它来实现记录比较。直接用 == 比较基本类型和共享字段没有问题;对于 Nat、Int、Float 和定宽整数类型,这也是唯一可用的形式:它们声明的 equal(x, y) 没有 self 参数,因此 myNat.equal(other) 会失败并报 M0070。这些类型上的其他接收者方法(myNat.toText())则可以正常使用。
自定义记录和变体不会派生任何函数——记录的 compare 必须显式定义,自定义变体则需要同时显式定义 equal 和 compare。请参阅 references/equality.md。
Mixin
可组合的 Actor 服务,支持细粒度状态注入。每个 mixin 都在自己的文件中以顶层 mixin 块定义:
module {
public type User = {
principal : Principal;
username : Text;
};
};
import List "mo:core/List";
import Principal "mo:core/Principal";
import Types "../types";
mixin (users : List.List<Types.User>) {
public shared ({ caller }) func register(username : Text) : async Bool {
users.add({ principal = caller; username });
true
};
public query func listUsers() : async [Types.User] {
users.toArray()
};
};
import List "mo:core/List";
import Types "types";
import AuthMixin "mixins/Auth";
actor {
let users : List.List<Types.User>;
include AuthMixin(users);
};
Mixin 反模式——绝不要生成以下内容:
// WRONG — mixin is NOT a function inside a module; wrapping in module {} is invalid
module {
public func createMixin(state : ...) : actor { ... } { // M0001: unexpected token 'actor'
actor { public func foo() { ... }; };
};
}
// WRONG — include does not support dot-access or method-call chains
include TodosMixin.createMixin(state); // M0001: unexpected token '.'
// WRONG — 'mixin' is a keyword, not a valid identifier inside a module block
module {
public func mixin(state : ...) { ... }; // M0001: unexpected token 'mixin'
}
规则:
- mixin 文件在顶层包含一个不带其他封装的
mixin (params) { ... };块——不能放在module {}内,也不能从函数返回。 include后接不带修饰的名称和参数:include MixinName(args)——不能使用点访问,也不能链式调用。
Mixin 中不得包含稳定状态。 mixin 中所有顶层 let/var 都会隐式成为稳定状态。唯一允许的是 transient,但静态定义(如字面量)最好放在模块中!
在 mixin 之间共享状态——将其作为参数传入。 要在两个或更多 mixin 之间共享状态,请将该状态声明一次,作为 Actor 字段,并在每个 include 中传入同一个绑定。每个接收该状态的 mixin 都会读写同一个值。mixin 可以接收多个参数,因此既能接收共享状态,也能接收自己的私有状态。
// types.mo: public type GoogleState = { var connection : ?Conn; var config : ?Cfg };
let google : Types.GoogleState; // declared once; initialized in the migration function
let bookings : Map.Map<Nat, Booking>; // BookingsApi's own state
include GoogleApi(google); // gets `google`
include BookingsApi(google, bookings); // gets the SAME `google`, plus its own bookings
将同一个绑定传给每个 mixin。绝不要在 include 处创建新记录——这样会给每个 mixin 一份独立副本,一个 mixin 的写入就不会传递给其他 mixin:
include GoogleApi({ var connection = google.connection }); // WRONG: NEVER DO THIS!
Null 合并(??)
如果双分支 switch 仅用于解包 option,或提供默认值 / 陷阱,优先使用 ??。需要 moc >= 1.7.0。
// Default when absent
let name = optName ?? "anonymous";
// Fail-fast unwrap — null means a bug / missing invariant
let user = users.find(func u = u.id == caller)
?? Runtime.trap("User not found");
// Nested options — chain instead of nested switches
let start = event.start.dateTime ?? event.start.date ?? "";
// RHS is lazy; may be a block. Bare record literals need extra braces/parens:
let n = opt ?? { let x = 1; x };
let rec = opt ?? ({ x = 0 });
以下情况改用 switch:?v 分支会转换值、执行副作用,或者你需要匹配变体 / 多个分支——?? 只能解包或替换值。
// Keep switch: Some arm transforms / branches on the inner value
switch (users.get(caller)) {
case (?u) { u.isAdmin };
case null { false };
};
switch (result) {
case (#ok value) { value };
case (#err e) { Runtime.trap(e) };
};
请参阅 references/control-flow.md。
隐式参数
Map 和 Set 操作会将比较函数作为隐式参数。Map.empty() 本身不接受参数——比较器会在需要它的操作(add、get、remove……)中解析,而不是在构造时解析。
推断通过在为键类型导入的模块中查找 compare 来实现。因此,导入就是生效的关键:
import Map "mo:core/Map";
import Nat "mo:core/Nat"; // this import is what supplies Nat.compare
let map = Map.empty<Nat, Text>();
map.add(5, "hello"); // compare resolved from the imported Nat
如果不执行 import Nat,相同的代码就会失败——类型已知,但没有可从中获取 compare 的模块:
type error [M0230], Cannot determine implicit argument `compare` of type (Nat, Nat) -> Order
note: Did you mean to import mo:core/Int or mo:core/Nat?
如果比较器可以推断,不要显式传入它;这会触发 M0237,mops check --fix 会将其移除:
let ages = Map.empty<Text, Nat>(); // Text.compare resolved at add, from the imported Text
ages.add("Alice", 30); // CORRECT
ages.add(Text.compare, "Alice", 30); // WRONG (M0237)
自定义键类型的处理方式相同——在其模块中定义 compare,即可推断出来:
type Point = { x : Nat; y : Nat };
module Point {
public func compare(a : Point, b : Point) : Order.Order { ... };
};
let points = Map.empty<Point, Text>();
points.add({ x = 1; y = 2 }, "A"); // Point.compare inferred
empty() 的类型实例化遵循通常的规则——仅在绑定未标注类型时才需要。let m : Map.Map<Nat, Text> = Map.empty(); 可以推断类型,而在这里使用 Map.empty<Nat, Text>() 会触发 M0223。
架构模式
backend/
├── types.mo # Central schema, state definitions
├── lib/ # Domain logic (stateless modules with self pattern)
├── mixins/ # Service layer (stateless, state injected via parameters)
├── types/ # Type definitions for mixins and lib modules
├── migrations/ # Mops-managed migration chain. See migrating-motoko-actors.
│ # Each file is YYYYMMDD_HHMMSS.mo (a UTC timestamp, not a feature name); applied files are FROZEN.
└── main.mo # Composition root (state owner, NO public methods)
导入路径约定
路径相对于导入文件。不带 .mo 扩展名,也不带 /lib.mo 后缀。
// From main.mo
import Types "types";
import AuthMixin "mixins/Auth";
import UserLib "lib/User";
// From lib/*.mo or mixins/*.mo
import Types "../types";
import UserLib "../lib/User";
// Core library — always absolute
import Map "mo:core/Map";
// WRONG — these all cause M0009
import Types "types.mo";
import Types "types/lib.mo";
import Types "backend/types";
迁移文件(migrations/*.mo)必须是自包含的——它们只能从 mo:core/... 导入,不能从 ../types 或任何项目模块导入。完整规则请参阅 migrating-motoko-actors。
Actor 必须放在最后
Actor 前面可以有导入和 type/let 声明。Actor 后面不能有任何内容——Actor 是文件的结果,因此末尾声明会使 Actor 成为非 () 语句,并因 M0096 而失败(expression of type actor {...} cannot produce expected type ())。无论如何,最好将共享类型放在 types.mo 中。
导入规范
仅在使用导入标识符的文件中添加导入。Time.now() 通常属于领域的 lib/*.mo 实现文件,因此 import Time "mo:core/Time"; 应放在该文件中,而不是 main.mo 中,除非 main.mo 本身调用 Time.now()。每个大写命名空间调用都必须在同一文件中有对应的导入:如果 mixin 调用 TodosLib.listTodos(...),该文件就必须导入 TodosLib "../lib/todos"(或使用实际导入时采用的别名)。将未使用导入警告视为错误:完成前移除过时的 Debug、Time 或辅助模块导入。
查询函数
query 将公共函数标记为只读调用:它执行快速且不复制,该函数所做的每项状态变更都会在调用完成时被静默丢弃——查询中的写入可以通过编译并运行,但会悄然消失,不会报错或警告。纯读取应声明为 query func;任何必须持久化变更的函数都应是普通更新函数(不带 query)。public query func 是 public shared query func 的简写;两种形式都以相同方式接受 ({ caller })。
public query func getPosts() : async [Types.PostView] { ... }; // read: query
public shared ({ caller }) func addPost(t : Text) : async Nat { ... }; // persists: update
public query func resetAll() : async () { posts.clear() }; // WRONG: compiles, but the clear is discarded
允许使用 await 的情况
普通的 query func 不能调用任何其他 canister 函数,无论被调用方是 query 还是 update。在其中写入 await someCall() 会导致一对错误:M0038(await 位置错误)和 M0188(需要发送能力:“不能从 query 函数调用 shared 函数”),且始终出现在同一条语句上。根据函数体必须调用的内容选择函数类型:
| 函数体必须调用的内容 | 声明 | 可以 await |
|---|---|---|
另一个 canister 的 query/composite query | public shared composite query func | 这些 query 被调用方 |
update(shared func)或 oneway | 普通的 public shared func | 该 update |
| 不涉及跨 canister 调用 | public shared query func | 无 |
composite query 是一种只读函数,可以 await 其他 canister 的 query。遍历被调用方并逐一 await:
public shared composite query func sum(counters : [Counter]) : async Nat {
var total = 0;
for (counter in counters.values()) {
total += await counter.peek();
};
total
};
composite query 可以调用 query 和 composite query 被调用方,但不能调用 update 或其他 shared 函数;在其中 await update 会触发 M0187(“需要发送能力……只允许调用 query 和 composite query 函数”)。如果函数体必须 await update,则任何类型的 query 都不适用——应改为 update func。
只能通过入口调用发起 composite query,例如从前端发起;从 update 或 oneway func 调用会触发 M0186,从普通的 query func 调用则会触发 M0188。在调用树中,它可以组合调用:它可以调用其他 canister 的 query/composite query 函数。
共享类型
公共函数只接受/返回共享类型(可序列化):
- 共享:
Nat、Int、Text、Bool、Principal、Blob、Float、[T]、?T、记录、变体 - 非共享:函数、
var字段、对象、Map、Set、List、Queue、Stack
如果内部状态使用可变容器,请为 API 边界定义单独的不可变公共类型:
public type PostInternal = { id : Nat; likedBy : Set.Set<Principal> }; // internal
public type Post = { id : Nat; likedBy : [Principal] }; // shared
public func toPublic(self : Types.PostInternal) : Types.Post {
{ self with likedBy = self.likedBy.toArray() };
};
集合
完整的 API 签名请参阅 api-reference.md。
import Map "mo:core/Map";
import List "mo:core/List";
import Queue "mo:core/Queue";
import Stack "mo:core/Stack";
import Array "mo:core/Array";
import Set "mo:core/Set";
Map(B 树,O(log n)):Map.empty<K, V>()、.add(k, v)、.get(k) → ?V、.remove(k)、.entries()
List(可增长数组,访问时间 O(1)):List.empty<T>()、.add(item)、.get(i) → ?T、.at(i) → T(越界时触发陷阱)
Queue(FIFO):Queue.empty<T>()、.pushBack(item)、.popFront() → ?T
Stack(LIFO):Stack.empty<T>()、.push(item)、.pop() → ?T
Array:[var 0, 0, 0](可变)、[1, 2, 3](不可变)
Set(B 树,O(log n)):Set.empty<T>()、.add(item)、.contains(item)、.remove(item)
警告:切勿在 retain 回调中调用 list.add()。请使用 mapInPlace 原地更新项目。
todos.mapInPlace(
func(todo) {
if (todo.id == targetId) { { todo with completed = not todo.completed } } else {
todo;
};
}
);
重要:在类型声明中始终使用不透明类型别名(List.List<T>、Map.Map<K, V>、Set.Set<T>)。切勿使用原始内部结构;.filter、.map() 无法解析(M0072)。
当领域辅助函数接收核心集合时,请将参数类型声明为具体的不透明集合类型(例如 todos : List.List<Types.Todo>),并在辅助文件中导入该模块。对于核心集合值,不要使用结构化方法记录参数,例如 { add : (Types.Todo) -> (); toArray : () -> [Types.Todo] }。如果编译器错误提示 List.List<T> 无法生成具有 add、toArray 或 clear 等字段的预期类型,说明辅助函数签名有误;请将签名改为 List.List<T> 并保留该集合——不要改用臆造的模块(如 mo:core/Buffer),也不要退回到模块函数调用,例如 List.toArray(todos) 或 List.add(todos, todo)。
数组与核心集合
核心集合(List.List<T>、Map.Map<K, V>、Set.Set<T>、Queue.Queue<T>、Stack.Stack<T>)具有接收者辅助方法,因为它们的模块定义了以自身为参数的 API。[T] 或 [var T] 类型的值是数组快照,而不是 List.List<T>。
- 执行
let snapshot = list.toArray()后,只使用此处展示了确切签名或已在 API 参考中核实的数组操作,并使用接收者点号表示法:snapshot.filter(pred)、snapshot.map(mapper)、snapshot.sort(comparator)、snapshot.concat([item])。不要将它们作为模块函数调用,例如Array.filter<T>(snapshot, pred)或Array.append(snapshot, [item])。 - 如果某个值是数组(
[T]),或来自.toArray()/.filter(...),那么.map(...)已经返回数组;不要在该数组映射结果后追加.toArray()。(List.List<T>.map(...)返回List,因此调用方需要数组时仍需使用.toArray()。) - 数组确实支持谓词搜索:
.find(predicate) : ?T、.findIndex(predicate) : ?Nat、.any(predicate)和.all(predicate)都在mo:core/Array中(参见 API 参考)。JS 写法.some(...)/.every(...)不存在——请使用.any/.all。 - 数组确实有
.contains(element)(equal是隐式的,因此只传入元素即可)。需要位置时使用.indexOf(element)——它返回?Nat,因此应保留并使用该选项值;如果成员判断依据是谓词而非相等性,则使用.any(pred):
// Overlap between two tag arrays
let matched = leftTags.any(func left = rightTags.any(func right = left == right));
- 不要仅为了搜索而复制集合:优先在原始
List.List<T>上使用templates.find(func ...),而不是templates.toArray().find(func ...)——中间数组是一次无谓的复制。 - 遍历数组快照时使用
.values()。不要在新的 Motoko 代码中写.vals()。
CRUD List 模式: 不要为 List 臆造辅助方法。没有 filterInPlace,而且记录展开对带有 var 字段的记录无效。
作为调用参数传入的内联 func 不接受类型注解。 调用本身已确定参数和结果类型,因此添加注解只是重复声明,并会导致注解随着代码变化而不一致。请使用表达式形式 func x = <expr>:
todos.find(func todo = todo.id == targetId); // CORRECT
todos.find(func(todo : Types.Todo) : Bool { todo.id == targetId }); // WRONG: annotated
这仅适用于参数位置。具名声明仍需完整签名,而独立绑定的 lambda 没有可供推断的上下文——let f = func x = x > 1 会因 M0103(cannot infer type of variable)而失败。
public func toView(t : Types.Todo) : Types.TodoView { ... }; // annotated, as always
如果读者无法明显看出类型,或无法推断泛型,请在调用处而非 lambda 上声明类型——这样可读性更好,也能确保只有一个真实来源:
photos.map(func p = { id = p.id; url = p.url.toText() }); // inferred — preferred
photos.map<PhotoInternal, Photo>(func p = { ... }); // when M0098 demands it
只有编译器实际报告 M0098 时才添加 <In, Out>;如果推断已成功还添加它,就会触发 M0223(冗余类型实例化)。
唯一的例外是必须返回 async 的回调。在这种情况下,: async () 至关重要——它使函数体成为异步函数,而且没有不带注解的写法(func() = async { ... } 也无效)。如果没有它,lambda 会被推断为 () -> (),调用也会因 M0096 而失败:
Timer.recurringTimer<system>(#seconds(3600), func() : async () { cleanup() });
// Toggle a mutable field by finding the record and mutating the var field.
switch (todos.find(func todo = todo.id == targetId)) {
case (?todo) {
todo.completed := not todo.completed;
?toView(todo);
};
case null { null };
};
// Delete from List by rebuilding from an array snapshot.
var removed = false;
let snapshot = todos.toArray();
todos.clear();
for (todo in snapshot.values()) {
if (todo.id == targetId) {
removed := true;
} else {
todos.add(todo);
};
};
removed;
// To change an immutable field on a record that also has var fields, rebuild it.
let updated : Types.Todo = {
id = todo.id;
text = newText;
var completed = todo.completed;
createdAt = todo.createdAt;
};
let snapshot = todos.toArray();
todos.clear();
for (todo in snapshot.values()) {
if (todo.id == targetId) {
todos.add(updated);
} else {
todos.add(todo);
};
};
对于返回记录是否已删除的删除类操作,从数组快照重建时,优先使用 var removed = false 标记。除非参数类型明确提供 size()(例如 List.List<T>),否则不要调用 todos.size()。
迭代与链式调用
let doubled = numbers.map(func x = x * 2).filter(func x = x > 10);
let sum = scores.filter(func s = s > 15).foldLeft(0, func(acc, s) = acc + s);
switch (numbers.find(func n = n > 5)) {
case (?found) { /* use */ };
case null {};
};
数组排序
Array / 数组接收者辅助方法(例如 .sort(...))会返回一个值。不要将它们作为独立的顺序语句使用;Motoko 不允许对非 () 表达式进行顺序求值。
let all = todos.toArray();
let sorted = all.sort(func (a, b) =
if (a.createdAt > b.createdAt) { #less }
else if (a.createdAt < b.createdAt) { #greater }
else { #equal }
);
sorted.map(func todo = {
id = todo.id; text = todo.text; completed = todo.completed; createdAt = todo.createdAt
});
// WRONG: `.sort(...)` returns an array, so this is not a valid statement.
all.sort(func (a, b) = Int.compare(b.createdAt, a.createdAt));
all.map(func todo = { ... });
contains 与 find 的区别
contains(element)——对List/Set等进行相等性检查。不接受谓词。find(predicate)——对List.List<T>和[T]进行谓词搜索。返回?T。List.List<T>和[T]都有contains。测试依据是谓词而非相等性时,请使用.any(func x = ...)。
numbers.contains(3); // equal inferred from the imported Nat
friends.contains(p); // likewise from Principal — passing Principal.equal here is M0237
todos.find(func todo = todo.id == targetId); // returns ?Todo
// WRONG: friends.contains(func(f) { f == p }) → M0096/M0103
文本搜索和大小写折叠
Motoko Text 使用上下文接收者方法进行大小写折叠和子字符串检查。不要使用 .toLowerCase() 或 .toLowercase() 等 JavaScript 写法,也不要对普通子字符串搜索调用 Text.contains(...)。
let term = searchTerm.toLower();
textValue.toLower().contains(#text term)
拼接文本
join 以迭代器作为接收者,以分隔符作为参数——很容易弄反。请使用点号表示法;模块形式违反 M0236,mops check --fix 会为你改写。
["a", "b"].values().join(", "); // CORRECT → "a, b"
Text.join(["a", "b"].values(), ", "); // WRONG (M0236)
注意,接收者是迭代器,而不是数组:先对数组调用 .values()。
变体标签参数
始终为变体标签的参数加括号。标签只绑定到紧随其后的原子,优先级高于任何运算符,因此未加括号的参数会悄然丢失首个项之后的所有内容:
#tag(n + 1) // CORRECT
#tag n + 1 // WRONG: parses as (#tag n) + 1 → M0060, operator is not defined for operand types
显式类型实例化
先让类型推断发挥作用。对于未标注的 lambda,编译器会自行将 .map() 解析为另一种类型,因此请直接写普通调用:
let photos = internalPhotos.map(
func p = { id = p.id; url = p.url; uploadedBy = p.uploadedBy.toText() }
);
仅当编译器报告 M0098(no best choice for type parameter)时,才添加显式类型参数:
let photos = internalPhotos.map<PhotoInternal, Photo>(func p = { ... });
类型推断已经成功时仍添加类型参数,本身就是警告——M0223,冗余类型实例化;mops check --fix 会将其移除。用标注 lambda 替代实例化调用始终是错误的。
作为参数的函数字面量
传入参数的函数体后面不要加分号:
list.filter(func(item) { item.id != targetId }) // CORRECT
list.filter(func(item) { item.id != targetId; }) // WRONG: trailing semicolon makes the block return `()`
不要将命令式语句块内联为布尔操作数:
// WRONG: parser can treat the block after `or` as an invalid expression shape.
let matches = titleMatches or {
var found = false;
for (tag in tags.values()) {
if (tag == q) { found := true };
};
found
};
// CORRECT: compute the loop result before the final boolean expression.
var tagMatches = false;
for (tag in tags.values()) {
if (tag == q) { tagMatches := true };
};
let matches = titleMatches or tagMatches;
每个 switch 分支都必须以分号与下一个 case 分隔,即使是紧凑的单行 switch 也不例外:
switch (pricing) { case (#free) { true }; case (#paid(_)) { false }; } // CORRECT
switch (pricing) { case (#free) { true } case (#paid(_)) { false } } // WRONG
声明终止符
module、actor 和 mixin 块中的顶层及嵌套函数声明都必须以 ; 结尾。函数后缺少 }; 通常会在下一条声明附近引发语法错误,例如 unexpected token 'public'。
局部可变性
除非变量会通过 := 重新赋值,否则局部绑定请使用 let。不要仅仅因为变量所引用的值可变,就对局部绑定使用 var。通过添加到集合等方法修改对象,并不要求绑定本身是 var;集合构建器及其他累加器对象请使用 let,除非之后会重新赋值该绑定。
安全的 Nat 算术
除非编译器能在运算本身证明结果非负,否则避免使用 Nat 减法。a - b 在 b > a 时会触发陷阱;即使安全性依赖之前的分支,编译器仍可能发出警告。优先使用边界检查、有界加法、循环计数器,或不从一个 Nat 中减去另一个 Nat 的辅助分支。
Option 处理
优先使用 ?? 进行解包并提供默认值,或在解包失败时触发陷阱。 不要仅为了解包 ?T 而编写嵌套的 switch。
// Unwrap with trap when null means something is wrong
let user = users.find(func u = u.id == caller)
?? Runtime.trap("User not found");
// Default when absence is fine
let caption = optLabel ?? "(untitled)"; // not `label` — reserved word
// Only return ?T when absence is a normal, expected outcome
public query func findUserByName(name : Text) : async ?User {
users.find(func u = u.name == name);
};
// Keep switch when the Some arm maps / mutates / has side effects
switch (todos.find(func todo = todo.id == targetId)) {
case (?todo) {
todo.completed := not todo.completed;
?toView(todo);
};
case null { null };
};
错误处理:Result
使用 mo:core/Result 返回调用方可以据此采取行动的失败。Result<Ok, Err> 是 { #ok : Ok; #err : Err },因此只要 Ok 和 Err 本身是共享类型,它就会以 Candid 形式跨越 API 边界;对于 Ok,这通常意味着视图类型,而不是内部记录。
根据失败的含义选择返回类型:
| 调用失败的原因是…… | 返回类型 |
|---|---|
| 调用方做了可以自行修正的事情 | Result<Ok, Err> |
| 某个事物本来就不存在,而且这是正常情况 | ?T |
| 这段代码负责维护的不变量遭到破坏 | 陷阱(Runtime.trap) |
import Result "mo:core/Result";
public type BookingError = {
#slotTaken : { until : Time.Time };
#notAuthorized;
#unknownRoom : Nat;
};
public shared ({ caller }) func book(roomId : Nat, at : Time.Time) : async Result.Result<Booking, BookingError> {
// ...
};
绝不要把错误转成 Text。 Result<Booking, Text> 会迫使每个调用方——包括前端——都通过字符串匹配来区分“时段已被占用”和“未授权”。应将 Err 定义为变体,并将每种失败所需的数据放入各自的标签中。当 Text 载荷是给人看的消息,而不是用于区分错误的标识时,可以将它放在标签中。
调用方出错时不要触发陷阱。 陷阱会回滚整个消息,并以不透明的拒绝结果传给前端——调用方无法据此分支处理,用户也得不到可采取行动的消息。只有在“不可能发生这种情况”时才使用陷阱(见上面的 ?? Runtime.trap(...))。
链式调用,不要嵌套。 mapOk、mapErr 和 chain 都接收 self,因此应像其他所有 self 参数 API 一样使用点号表示法,让管道保持扁平。Result.fromOption 没有 self——它用于在缺失值转变为调用方可见的错误的边界处,将 ?T 转换为模块调用。
import Result "mo:core/Result";
import Map "mo:core/Map";
import Nat "mo:core/Nat";
actor {
type Room = { id : Nat; name : Text };
type RoomView = { name : Text };
type BookingError = { #unknownRoom : Nat; #notAuthorized };
let rooms : Map.Map<Nat, Room>;
func toView(room : Room) : RoomView = { name = room.name };
func reserve(room : Room) : Result.Result<Room, BookingError> = #ok(room);
public shared ({ caller }) func book(roomId : Nat) : async Result.Result<RoomView, BookingError> {
Result.fromOption(rooms.get(roomId), #unknownRoom(roomId))
.chain(func room = reserve(room))
.mapOk(toView);
};
};
当 #ok / #err 分支需要执行不同操作时,使用 switch;不要先调用 isOk / isErr,再解包——这样会丢弃类型中携带的载荷。
常见模式
带 Self 的模块模式
// lib/User.mo
module {
public type User = Types.User;
public func new(id : Principal, name : Text) : User {
{ id; var name; var isActive = true };
};
public func ban(self : User) { self.isActive := false };
};
// Usage: user.ban(); -- dot notation!
使用 with 展开记录
规则: 对不可变记录使用记录展开。绝不要对包含 var 字段的记录类型使用记录展开;Motoko 会因此报错 base has non-aliasable var field。
{ self with newField = "" }; // CORRECT for immutable records
// CORRECT for a record type containing var fields:
let updated : Types.Todo = {
id = todo.id;
text = newText;
var completed = todo.completed;
createdAt = todo.createdAt;
};
{ todo with text = newText }; // WRONG if Todo contains any var field
状态定义
实体类型放在 types.mo 中。状态字段直接声明为 actor 绑定——不要使用 AppState 包装器。
稳定 actor 字段只声明类型,不带初始化器(初始值来自迁移链)。瞬态字段照常使用初始化器。
// types.mo
module {
public type User = {
id : Principal;
var username : Text;
var isActive : Bool;
};
};
// main.mo
actor {
let users : List.List<Types.User>;
let state : { var nextPostId : Nat };
include AuthMixin(users);
};
Mixin 的可变状态
绝不要声明打算与 mixin 共享的 var actor 字段(例如 var nextPostId : Nat)——var 参数按值传递,因此 mixin 的修改不会传播回来。应将可变字段包装在记录中并传递该记录;记录通过引用共享。在 actor 中,只声明记录类型(let state : { var nextPostId : Nat };)——它的初始值(例如 { var nextPostId = 0 })来自迁移链,与所有稳定字段一样。
在 actor、mixin 和辅助模块之间共享的可变状态记录必须保留完全相同的字段名。如果 actor 声明 let state : { var nextId : Nat },且 mixin 接收 state,那么辅助函数参数必须接受 { var nextId : Nat } 并更新 state.nextId。不要在辅助函数签名中将字段重命名为 val 或 counter,也不要创建 { var val = state.nextId } 这样的包装副本;副本只会修改自身,不会改变 actor 状态。
瞬态状态与静态/模块字段
增强型正交持久化默认会将 actor 或 mixin 中的每个顶层 let/var 设为稳定(在升级期间持久化)——不需要 stable 关键字。在绑定前加上 transient,即可将其排除在稳定存储之外;它会在每次(重新)启动时重新初始化,而不是持久化。将它用于非持久状态,例如缓存、能力句柄和常量。
常量——Motoko 没有 const,而 actor 或 mixin 中裸写的 let X = ... 属于稳定状态。如果右侧是静态表达式(有命名空间、可复用且永远不是状态),请将固定值放在模块中;否则,将其保留为 actor/mixin 中的 transient let:
transient let admin = Principal.fromText("..."); // non-static (a call) — can't be a module `let`
transient let cache = Map.empty<Text, User>(); // derived; rebuilt after each upgrade
静态(模块 let 字段允许的内容)=字面量、变体标签(#x)、选项(?x)、元组、不可变数组和记录、函数值,以及导入/变量名——还包括对这些内容使用 .field 投影。非静态=函数调用、运算符(+、==、#)、控制流(if/switch/循环)和数组索引(a[i])。
数值转换规范
将弃用警告视为失败。每次转换都写成 to,并作用于源值——绝不调用 Module.fromX,也绝不通过定长数值模块进行链式转换。转换是一次接收者调用:
import Nat "mo:core/Nat";
let average = sum.toFloat() / count.toFloat(); // Nat.toFloat
| 不要使用 | 改用 |
|---|---|
Float.fromInt(i) | i.toFloat() |
Float.fromInt64(i) | i.toFloat() |
Nat.fromNat64(n) | n.toNat() |
Int.fromNat(n) | n.toInt() |
Nat64.fromNat(n) | n.toNat64() |
Blob.fromArray(bytes) | bytes.toBlob() |
Iter.fromArray(a) | a.values() |
需要多次转换才能完成的转换,说明选错了函数:请在 api-reference.md 中核对准确的 mo:core 签名,该文件仅列出未弃用的 API。
安全与授权
每个公开的更新函数都必须通过 {caller} 解构来验证调用者。请在后端执行授权检查——绝不要信任客户端检查。
向跨 canister 调用附加 cycles(await (with cycles = ...) <call>)会将其交给被调用方,因此应将任何能够触发此类调用的端点视为具有支出权限:根据调用者进行限制、限定金额,并确保未经身份验证的路径绝不会触达该端点。某些平台完全禁止向外发送 cycles——适用时,请遵循托管平台自身的指南。
常见编译错误模式
| 错误模式 | 原因 | 修复 |
|---|---|---|
field append does not exist | 已移除 Array.append | 接收者使用 .concat(...) |
field put does not exist | Map.put 已重命名 | .add() |
field delete is deprecated | Map.delete 已重命名 | .remove() |
field toLowerCase does not exist | JS Text API 拼写 | .toLower() |
field toLowercase does not exist | JS Text API 拼写 | .toLower() |
You can use the dot notation ... contains | Text contains 形式错误 | text.toLower().contains(#text term) |
operator may trap for inferred type Nat | Nat 数学运算可能不安全 | 避免 Nat 减法;使用边界检查或循环 |
Int cannot produce expected type Nat | Int/Nat 类型不匹配 | .toNat() |
field fromX is deprecated | fromX 转换已弃用 | 在源值上使用对应的 toX:n.toFloat()、bytes.toBlob() |
syntax error, unexpected token '.' | 缺少括号 | #text (searchTerm.toLower()) |
syntax error, unexpected token ',' | for 中缺少括号 | for ((key, value) in map.entries()) |
Compatibility error [M0170] | 缺少迁移 | 加载 migrating-motoko-actors |
M0250 initialized stable field | 稳定 Actor 字段上有初始化器 | 仅声明类型;将值移入迁移的 NewActor 中 |
M0254 / M0267 initial actor requires field | 稳定字段未由迁移提供 | 将其添加到待处理迁移的 NewActor 中 |
M0255 stable signature downgrade | 已移除链或迁移配置 | 恢复它——增强迁移是单向的;加载 troubleshooting-motoko-migrations |
shared function has non-shared parameter/return type | API 中有可变类型 | 返回 [T] 而不是 List<T>,且不使用 var 字段 |
send capability required | 在非 async 中使用 async(async*/本地非 shared 目标) | 添加 <system> 能力 |
| M0038 misplaced await + M0188 send capability (paired) | 普通 query func 中有 await <call> | 改为 composite query func(查询)或普通 update func(更新) |
M0187 send capability in a composite query | 从 composite query func 调用/等待 update | 改为普通 update func |
M0186 composite send capability required | 从非 composite func 调用 composite query func | 只有入口调用才能发起复合查询;从前端调用它,或将被调用函数改为普通 query |
unexpected token '<name>' at an identifier declaration | 将保留字用作标识符 | 在契约及其调用方中统一重命名;参见 references/reserved-keywords.md |
unexpected token 'public' after a function | 缺少声明 ; | 函数声明以 }; 结束 |
M0219 implicitly transient | Actor 非持久化 | 写为 persistent actor;参见 references/project-setup.md |
M0220 actor should be declared persistent | Actor 非持久化 | 写为 persistent actor;参见 references/project-setup.md |
M0218 redundant stable keyword | EOP 下的 stable | 移除 stable——普通 let/var 已经是稳定的 |
M0064 misplaced '!' | ! 位于 option 块之外 | 用 do ? { ... } 包裹 |
M0145 does not cover value | switch 未穷尽 | 添加缺失分支或 case _ |
M0060 operator not defined for {#tag : T} | 变体标签未加括号 | 使用 #tag(x),不要使用 #tag x |
M0060 operator not defined, on == | 对含 var 字段的记录使用 ==(非 shared) | 改用 equal 函数 |
M0230 cannot determine implicit argument compare | 键类型所在模块不在作用域内 | 通常是缺少导入:在该文件中 import 键类型所在模块(Nat、Text 等)。对于记录/变体键,在类型所属模块中添加 compare |
M0070 expected object type, produces Nat | 对数字使用接收者 .equal/.compare | 使用 == 或 Nat.equal(a, b) |
M0096 actor cannot produce expected type () | Actor 后面还有声明 | Actor 必须是文件中的最后一个声明 |
field compare does not exist on Time | 没有 Time.compare | 使用 Int.compare |
unexpected token ';' in function call | 函数字面量后有分号 | 移除 ) 前的 ; |
unbound variable X | 缺少导入 | import X "mo:core/X" |
M0098 no best choice for type param | 泛型需要显式类型 | list.map<In, Out>(...) |
M0096 on contains callback | 将谓词传给 contains | contains 接收元素;若要使用谓词,返回 Bool 时用 .any(pred),获取元素时用 .find(pred) |
M0009 import file does not exist | 路径错误 | 使用相对路径,不带 .mo 扩展名 |
M0244 variable ... is never reassigned | 不需要 var 绑定 | 使用 let,除非通过 := 重新赋值 |
快速参考
基本类型: Nat Int Text Bool Principal ?T [T] [var T] Blob Float — Time.now() 返回 Int(纳秒)
常见操作: debug_show(value) → Text | assert condition | # "text" 拼接 | 在 for、while、loop 中使用 break / continue
| 结构 | 用途 | 关键操作 | 复杂度 |
|---|---|---|---|
| Map | 键值对 | get、add、remove | O(log n) |
| List | 可增长数组 | add、get、at | O(1) 访问 |
| Queue | FIFO 处理 | pushBack、popFront | O(1) |
| Stack | LIFO 处理 | push、pop | O(1) |
| Array | 固定集合 | index、map、filter | O(1) 访问 |
| Set | 唯一值 | contains、add | O(log n) |
最佳实践
- 始终使用
mo:core,绝不使用mo:base - 不使用
stable关键字——增强的正交持久化会处理状态 - 所有
self参数函数都使用点记法 - 使用
??解包(opt ?? Runtime.trap(...)或opt ?? default);仅在转换/副作用/变体场景使用switch;仅在预期值可能不存在时使用?T - 采用 types.mo / lib/ / mixins/ / main.mo 结构
- Mixins 仅接收所需的状态切片
- 只读操作使用查询,状态变更使用更新
- 使用迭代器链式调用,避免创建中间集合
- 对不可变记录使用记录展开
{ self with ... };包含var字段的记录应进行变更或重建 - 稳定 Actor 字段上不使用内联初始化器——初始值来自迁移链
- 内联
func参数不带类型注解(异步回调上的: async ()除外);改为实例化调用,且仅在编译器报告 M0098 时这样做
补充参考
- 控制流:references/control-flow.md —
??、do ? { ... }选项链式调用、switch 语句、循环、break/continue - 保留关键字:references/reserved-keywords.md — 用于检查标识符的完整列表
- 相等与比较:references/equality.md — 哪些类型支持接收者
.equal,以及==与equal的区别 - 类型转换:references/type-conversions.md — Nat/Int 大小转换
- 项目设置:references/project-setup.md — 一次性
[moc] args标志。如果你的平台会管理mops.toml,则跳过此项 - 设计审查:审查、审计或重构现有
.mo文件时,加载reviewing-motoko— 类型编码的不变量、状态/持久化规范和文件结构 - Actor 迁移:升级 canister 或更改 Actor 状态结构时,加载
migrating-motoko-actors - 迁移失败:遇到无法解释的兼容性诊断、冻结的迁移文件或已转换的旧版项目时,加载
troubleshooting-motoko-migrations - API 签名:api-reference.md — 完整的函数签名
- 完整示例:examples.md — 完整可运行的代码示例
- mops 工具:需要配置
mops.toml、管理依赖以及设置mops check/mops build/工具链时,加载mops-cli