mcpField
W
writing-motoko

writing-motoko

github.com/dfinity开发3987 次安装今天更新
代码仓库

关于 writing-motoko

面向 Internet Computer Protocol 开发者的 Motoko 编码技能,提供语言与工具链指导。内容强调非弃用的 `mo:core` API、模块化 Actor 架构、迁移流程和编译检查。

主要功能

  1. 01最新 Motoko 语言指导
  2. 02`mo:core` API 与点调用规范
  3. 03多文件 Actor 和 mixin 模式
  4. 04Actor 状态迁移指导
  5. 05mops 依赖、检查与构建流程

适用场景

  1. 01实现 Motoko canister 功能
  2. 02将 Actor 代码拆分为多个模块
  3. 03排查 API 与编译错误
  4. 04管理依赖和 Actor 状态升级

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/dfinity/icskills --skill writing-motoko

安装后在 Claude Code 中输入/writing-motoko确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
api-reference.md
control-flow.md
equality.md
examples.md
project-setup.md
reserved-keywords.md
type-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+ 版本(编译器 moc 1.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:core API,或编译诊断提示 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;请使用 mops CLI,以确保依赖元数据与锁文件保持同步。([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 querypublic 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 existMap.put 已重命名.add()
field delete is deprecatedMap.delete 已重命名.remove()
field toLowerCase does not existJS Text API 拼写.toLower()
field toLowercase does not existJS Text API 拼写.toLower()
You can use the dot notation ... containsText contains 形式错误text.toLower().contains(#text term)
operator may trap for inferred type NatNat 数学运算可能不安全避免 Nat 减法;使用边界检查或循环
Int cannot produce expected type NatInt/Nat 类型不匹配.toNat()
field fromX is deprecatedfromX 转换已弃用在源值上使用对应的 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 typeAPI 中有可变类型返回 [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 transientActor 非持久化写为 persistent actor;参见 references/project-setup.md
M0220 actor should be declared persistentActor 非持久化写为 persistent actor;参见 references/project-setup.md
M0218 redundant stable keywordEOP 下的 stable移除 stable——普通 let/var 已经是稳定的
M0064 misplaced '!'! 位于 option 块之外用 do ? { ... } 包裹
M0145 does not cover valueswitch 未穷尽添加缺失分支或 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将谓词传给 containscontains 接收元素;若要使用谓词,返回 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、removeO(log n)
List可增长数组add、get、atO(1) 访问
QueueFIFO 处理pushBack、popFrontO(1)
StackLIFO 处理push、popO(1)
Array固定集合index、map、filterO(1) 访问
Set唯一值contains、addO(log n)

最佳实践

  1. 始终使用 mo:core,绝不使用 mo:base
  2. 不使用 stable 关键字——增强的正交持久化会处理状态
  3. 所有 self 参数函数都使用点记法
  4. 使用 ?? 解包(opt ?? Runtime.trap(...) 或 opt ?? default);仅在转换/副作用/变体场景使用 switch;仅在预期值可能不存在时使用 ?T
  5. 采用 types.mo / lib/ / mixins/ / main.mo 结构
  6. Mixins 仅接收所需的状态切片
  7. 只读操作使用查询,状态变更使用更新
  8. 使用迭代器链式调用,避免创建中间集合
  9. 对不可变记录使用记录展开 { self with ... };包含 var 字段的记录应进行变更或重建
  10. 稳定 Actor 字段上不使用内联初始化器——初始值来自迁移链
  11. 内联 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

评论

支持 Markdown