mcpField
M
motoko-general-style-guidelines

motoko-general-style-guidelines

github.com/research-ag开发059 次安装10 周前更新
代码仓库

关于 motoko-general-style-guidelines

面向 Motoko 开发者的风格指南,旨在提升源代码的可读性与一致性,涵盖排版、标点、注释和命名约定。

主要功能

  1. 01运算符空格与换行
  2. 02使用两空格缩进,不用制表符
  3. 03定义分组与注释规范
  4. 04分号与花括号用法
  5. 05标识符命名约定

适用场景

  1. 01统一 Motoko 代码格式
  2. 02检查代码的命名与标点风格

安装

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

$npx skills add https://github.com/research-ag/motoko-skills --skill motoko-general-style-guidelines

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

详细介绍

Motoko 通用风格指南

介绍

为提高 Motoko 源代码的可读性和一致性,本风格指南为 Motoko 源代码的格式设置及其他基本约定提供建议。

排版

空格
  • 算术运算符两侧加空格,但若要将优先级更高的运算符子表达式在视觉上分组,则不必加空格。

    let z = - 2*x + 3*y + 4*(x*x + y*y);
    
  • 比较运算符、布尔运算符和赋值运算符两侧加空格。

    4 + 5 <= 5 + 4;
    not (a or b and not c);
    v := 0;
    v += 1;
    
  • = 两侧加空格。

    var v = 0;
    let r = { a = 1; b = 2 };
    
  • 同样,: 两侧加空格。

    var v : Nat = 0;
    func foo(x : Nat, y : Nat) : Nat { x + y }
    func bar((x, y) : (Nat, Nat)) : Nat { x + y }
    let w = 1 ^ 0xff : Nat16;
    

    理由:在声明中,: 的作用相当于定义中的 =。此外,类型注解左侧通常可以是任意复杂的表达式或模式。

  • 逗号或分号后加空格,前面不加。

    let tuple = (1, 2, 3);
    let record = { a = 1; b = 2; c = 3 };
    
  • 大括号内加空格,简单变体或记录除外。

    func f() { 0 };
    f({ a = 1; b = 2; c = 3 });
    f({a = 1; b = 2});  // 这样也可以
    
    type Vec3D = { x : Float; y : Float; y : Float };
    type Order = { #less; #equal; #more };
    
    type Order = {#less; #equal; #more};  // 这样也可以
    type Proc = {h : Nat; w : Nat} -> {#ok; #fail};
    
  • 多行展开时,方括号内加空格。

    foo(
      firstArgument,
      ( longTupleComponent, anotherLongExpression,
        moreLongExpression
      ),
      [ 1, 2, 3,
        4, 5, 6,
      ],
      { field1 = 4; field2 = 5;
        field3 = 6;
      }
    );
    
  • 语句关键字与其操作数之间加空格。

    if (f()) A else B;
    for (x in xs.values()) { ... };
    switch (compare(x, y)) {
      case (#less) { A };
      case (_) { B };
    }
    
    assert (x < 100);
    await (async (0));
    
  • 函数或变体标签与其参数元组之间,以及泛型类型参数列表两侧,不加空格。

    type Pair<X> = (X, X);
    type Id = <X>(X) -> X;
    
    let ok = #ok(5);
    
    func id<X>(x : X) : X { x };
    id<Nat>(5);
    
  • 如果参数不是元组、带括号的表达式(参见括号)或用作具名参数列表的记录(参见选择类型),则函数与参数之间加空格。

    sin 0.0;
    g [1, 2, 3];
    f{arg1 = 0; arg2 = 0};
    

理由:尤其是 g[1] 会被误解析为索引操作。

  • 访问运算符(如 .、?、!)或索引方括号两侧不加空格。

    foo(bar).baz[5]().boo;
    foom(?(bam()! + 1));
    
换行
  • 为每行设置固定的右边距,并拆分定义或表达式。许多人认为 80 列仍是合适的限制。

    let sum = a + b + 2*c + d +
      e + f + g + h + i + k +
      l + m + n + o + p;
    
    // Or:
    let sum =
      a + b + 2*c + d + e +
      f + g + h + i + k + l +
      m + n + o + p;
    

    理由:采用这种格式还有许多其他好处,例如:

    1. 避免代码在窗口右侧被隐藏。

    2. 避免并排差异比较中出现随机换行。例如,GitHub 或类似的代码审查工具所显示的情况。

    3. 让代码在纸张、网站或其他媒介上显示得更美观。

  • 在运算符后换行。

    a + b + c +
      d + f;
    
    foo(bar, baz).
      boo();
    
  • 拆分函数定义或包含较长参数列表的调用时,每个参数单独占一行。

    另外,也可以考虑对较长的参数列表使用记录,参见选择类型。

    func someFunction(
      arg1 : FirstType,
      arg2 : SecondType,
      anotherArg : Nat,
      yetAnother : [Type],
      func : Nat -> Nat,
    ) : Nat {
      ...
    };
    
    someFunction(
      veryLongArgumentExpression,
      anotherVeryLongArgumentExpression,
      3,
      aNestedFunctionCall(
        alsoWithLongArguments,
        andMoreSuchArguments,
      ),
      moreLongishArgument,
    );
    

    理由:这样可以避免阅读代码时漏看参数,也能避免修改某个表达式后需要重新调整换行。

缩进
  • 每级缩进应为 2 个空格。

    actor A {
      public func f() {
        return;
      }
    }
    

    理由:代码可能会有很多层嵌套。只使用 2 个空格可以避免浪费屏幕空间。

  • 缩进不应取决于前几行的词法内容。

    尤其是,不要让缩进与前几行中的字符垂直对齐。

    let x = someFunction(
      arg1, arg2, arg3, arg4, arg5);               // 请这样做。
    
    let x = someFunction(arg1, arg2, arg3,
      arg4, arg5);                                 // 或者这样做。
    
    let x =
      someFunction(arg1, arg2, arg3, arg4, arg5);  // 或者这样做。
    
    let x = someFunction(                          // 或者这样做。
      longArg1,
      longArg2,
      longArg3,
      longArg4,
      longArg5,
    );
    
    // 反例!
    let x = someFunction(arg1, arg2, arg3,
                         arg4, arg5);              // 不要这样做!
    

    理由:垂直对齐有许多问题,例如:

    1. 浪费大量水平空间。

    2. 会造成极不一致的缩进层级,掩盖代码结构。

    3. 修改某一行时可能需要重新对齐,即使编辑器会自动处理,也会使差异比较变得臃肿且难以辨认。

    4. 在宽度可变的字体中会完全失效。

    经验法则:所有缩进都应是 2 的倍数。

  • 不要使用制表符。

    理由:不同工具对制表符的解释差异很大,而且在许多场景中(例如网页和差异比较)制表符可能会丢失或显示不正确。

分组
  • 用空行分隔复杂的多行定义。单行定义可以连续排列。

    func foo() {
      // 此函数会执行许多有趣的操作。
      // 它的定义跨越多行。
    }
    
    func boo() {
      // 这是另一个复杂的函数。
      // 它的定义也跨越多行。
    }
    
    func add(x : Nat, y : Nat) { return x + y };
    func mul(x : Nat, y : Nat) { return x * y };
    
  • 用两个空行分隔不同逻辑组的定义。为每个组添加一行注释作为“节标题”。

    // 一个很大的类
    class MuffleMiff(n : Nat) {
    
      // 访问器
    
      public func miffMuff() : Text {
        ...
      }
    
      public func sniffMiff() : Nat {
        ...
      }
    
      // 修改器
    
      public func clearMurk() {
        ...
      }
    
      public func addMuff(name : Text) {
        ...
      }
    
      // 处理
    
      public func murkMuffle(param : List<Gnobble>) {
        ...
      }
    
      public func transformSneezler() {
        ...
      }
    
      // 内部状态
    
      var miffCount = 0;
      var mabbleMap = Map<Nat, Text>();
    
    }
    
注释
  • 使用行注释(//…​)。仅在行中间注释或开发期间注释掉部分代码时,才使用块注释(/* …​ */)。

    // 以下函数会对给定的 snibble 执行当前的
    // pallaboom。如果可以,它会返回
    // 合适的 plexus。
    func paBoom(s : Snibble) : Handle<Plexus> {
      let puglet = initPugs(s.crick, 0 /* size */, #local);
    /* 暂时不要执行那些奇怪的操作……
      ...
      ...
    */
      return polyfillNexus(puglet);  // 暂时如此
    }
    

    原因:行注释便于插入、删除或交换单独的行。

  • 在行尾添加解释单行代码的简短注释,并与代码至少间隔两个空格。

    paBoom(getSnibble()));  // 创建新的 snibble
    
  • 多行注释放在代码行之前,并与所描述的代码使用相同的缩进。

    func f() {
      // 尝试使用上一个 snibble 调用当前的 pallaboom。
      // 如果成功,我们就得到了新的 plexus;
      // 如果失败,则报告错误。
      let plexusHandle = paBoom(getSnibble()));
    }
    
  • 单独成行的注释首字母大写。句子末尾使用规范的句号。

标点

分号
  • 在代码块中,无论前面的声明是否以右花括号 } 结尾,Motoko 都一律要求使用分号分隔表达式或局部声明。

    原因:这与其他类 C 语言不同;这些语言往往采用相当随意的规则。

  • 在代码块的最后一个表达式后添加分号,除非整个代码块写在同一行。

    类型定义也同样如此。

    // 同一行的右花括号前不需要 ;
    
    type Vec3D = {x : Float; y : Float; z : Float};
    type Result<A> = {#ok : A; #error : Text};
    
    func add(x : Nat, y : Nat) : Nat { return x + y };
    
    // 最后一个分支以 ; 结尾
    
    type Address = {
      first : Text;
      last : Text;
      street : Text;
      nr : Nat;
      zip : Nat;
      city : Text;
    };
    
    type Expr = {
      #const : Float;
      #add : (Expr, Expr);
      #mul : (Expr, Expr);
    };
    
    func eval(e : Expr) : Float {
      switch (e) {
        case (#const(x)) { x };
        case (#add(e1, e2)) { eval(e1) + eval(e2) };
        case (#mul(e1, e2)) { eval(e1) * eval(e2) };
      };
    }
    

    原因:统一在行尾添加分号,可以简化行的添加、删除或交换。

花括号
  • 函数体、if 或 case 分支以及循环体都要加上花括号,除非它们作为表达式嵌套出现,且只包含单个表达式。

    func f(x) { f1(x); f2(x) };
    
    let abs = if (v >= 0) v else -v;
    let val = switch (f()) { case (#ok(x)) x; case (_) 0 };
    func succ(x : Nat) : Nat = x + 1;
    
  • 多行花括号子表达式采用“C 风格”布局。

    func f() {
      return;
    };
    
    if (cond) {
      foo();
    } else {
      bar();
    };
    
    switch (opt) {
      case (?x) {
        f(x);
      };
      case (null) {};
    };
    
圆括号
  • Motoko 支持“省略圆括号”风格,也就是说,在大多数情况下圆括号都是可选的,例如函数参数列表或语句操作数,只要它们括起的表达式本身已经有括号即可。比如元组、对象或数组,或简单的常量或标识符。

    type Op = Nat -> Nat;
    let a2 = Array.map<Nat, Nat>(func x { x + 1 }, a);
    
    let y = f x;
    let z = f {};
    let choice = if flag { f1() } else { f2() };
    
    switch opt {
      case null { tryAgain() };
      case _ { proceed() };
    };
    
  • 避免过度使用省略圆括号的风格。

    尤其是,不要同时在语句中省略圆括号和花括号。

    // 反例!
    let choice = if flag x + y else z;  // 不要这样做!
    
    switch val {
      case 0 f();    // 不要这样做!
      case n n + 1;  // 也不要这样做!
    };
    

    理由:同时省略两者会减少代码分组的视觉提示,使代码更难阅读。

  • 同样,如果函数还包含类型参数,就不要省略函数参数周围的圆括号。

    // 反例!
    foo<Nat> 0;   // 不要这样做!
    
  • 如果函数类型只有一个参数且没有类型参数,则省略其参数类型周围的圆括号。

    但如果函数或类也有类型参数,则不要省略这些圆括号。

    type Inv = Nat -> Nat;
    type Id = <T>(T) -> T;
    type Get = <X>(C<X>) -> X;
    
    // 反例!
    type Get = <X>C<X> -> X;   // 不要这样做!
    
其他
  • 使用 _ 对数字进行分组。

    十进制数每 3 位分组,十六进制数每 4 位分组。

    let billion = 1_000_000_000;
    let pi = 3.141_592_653_589_793_12;
    let mask : Nat32 = 0xff00_ff0f;
    

命名

风格
  • 类型名称(包括类或类型参数)、模块名称和 actor 名称使用 UpperCamelCase。

  • 所有其他名称(包括常量和变体字段)使用 lowerCamelCase。

    module MoreMuff {
      type FileSize = Nat;
      type Weekday = {#monday; #tuesday; #wednesday};
      type Pair<X> = (X, X);
    
      class Container<X, Y>() { ... };
    
      func getValue<Name>(name : Name) : Pair<Name> { ... };
    
      let zero = 0;
      let pair = getValue<Text>("opus");
      var nifty : Nat = 0;
    
      object obj { ... };
    
      actor ServerProxy { ... };
    };
    

    理由:通常约定,类型等“静态”实体使用大写开头,动态值使用小写开头。模块和 actor 比较静态,并且可以导出类型。对象通常不导出类型,且主要用作动态值。

  • 缩写词按普通单词的形式拼写。

    type HttpHeader = ...;
    func getUrl() { ... };
    let urlDigest = ...;
    
  • 标识符名称不要以下划线 _ 开头,除非用于说明模式中的变量有意不使用。

    let (width, _color, name) = rumpler();
    ...  // _color is not used here
    
    func foo(x : Nat, _futureFlag : Bool) { ... };
    

    理由:类型检查器可能会对未使用的标识符发出警告。可以在名称前显式添加 _ 来说明这是有意为之,从而抑制警告。

    这与将关键字 _ 用作模式通配符的用法一致。

命名约定
  • 返回值的函数名称应描述该值。

    避免使用多余的 get 前缀。

    dict.size();
    list.first();
    sum(array);
    
  • 执行副作用或复杂操作的函数名称应描述该操作。

    dict.clear();
    dict.set(key, value);
    let result = traverse(graph);
    
  • 返回 Bool 的谓词函数应使用 is 或 has 前缀,或使用类似的描述来说明所测试的属性。

    class Set<X>() {
      public func size() : Nat { ... };
    
      public func add(x : X) { ... };
      public func remove(x : X) { ... };
    
      public func isEmpty() : Bool { ... };
      public func contains(x : X) : Bool { ... };
    };
    
  • 转换为类型 X 或从类型 X 转换的函数,分别命名为 toX 和 fromX;条件是源类型或目标类型为该函数所属的对象,或该函数所在模块的主要类型。

  • 在类或对象中,使用以 _ 结尾的名称来区分私有变量和 getter。

    class Cart(length_ : Nat) {
      var width_ = 0;
    
      public func length() : Nat { return length_ };
      public func width() : Nat { return width_ };
    }
    

    原因:在 Motoko 中,函数是一等值,因此函数和其他值标识符共享同一命名空间。

    不应使用以下划线 _ 开头的标识符来表示私有状态,因为这表示该名称未使用(参见 style)。

  • 对于全局或公开标识符,以及作用域较大的标识符,使用更长、更具描述性的名称;对于作用域较小的局部标识符,使用较短的名称。

    如果没有特别需要说明的内容,使用单字符标识符也可以,尤其是在一致地采用同一命名方案时。

    func map(x : Nat, y : Nat) : Nat { x + y };
    
    func eval(e : Expr) : Nat {
      let n =
        switch (e) {
          case (#neg(e1)) { - eval(e1) };
          case (#add(e1, e2)) { eval(e1) + eval(e2) };
          case (#mul(e1, e2)) { eval(e1) * eval(e2) };
        };
      Debug.print(n);
      return n;
    };
    

    原因:与普遍看法相反,过于冗长的局部名称会增加噪声,反而降低可读性。

  • 在适当情况下,使用复数形式来描述一组项目,例如列表或数组。

    短名称也可以采用这种方式。

    func foreach<X>(xs : [X], f : X -> ()) {
      for (x in xs.values()) { f(x) }
    }
    

类型

类型注解
  • 对涉及定宽数值类型的定义添加类型注解,以消除重载算术运算符和常量的歧义。

    let mask : Nat32 = 0xfc03_ff00;
    let pivot : Nat32 = (size + 1)/2;
    let vec : [Int16] = [1, 3, -4, 0];
    

    :::note

    使用浮点常量可在不添加额外注解的情况下强制使用 Float 类型。同样,如果需要,可显式添加 + 号,将值设为 Int 类型的正数,而不是 Nat。

    :::

    let zero = 1.0;    // type Float
    let offset = +1;   // type Int
    
  • 同样,对于类型不是 Nat 或 Int 的算术表达式,应添加内联类型注解。

    if (x & mask == (1 : Nat32)) { ... };
    

    :::note

    在这类情况下需要为常量添加注解,是 Motoko 类型系统目前的不足之处,我们希望很快能解决。

    :::

    函数参数无需添加注解,因为其类型通常可从函数推断得出。唯一的例外是参数具有泛型类型且省略了类型实参。

    func foo(len : Nat32, vec : [Nat16]) { ... };
    func bar<X>(x : X) { ... };
    
    foo(3, [0, 1, 2]);
    bar<Nat16>(0);
    bar(0 : Nat16);
    
  • 为可变变量添加类型注解,除非其类型显而易见。

    var name = "Motoko";
    var balance = 0;
    
    func f(i : Int) {
      var j = i;
    };
    
    var balance : Int = 0;
    var obj : Class = foo();
    

    原因:由于子类型关系,从初始化值推断出的类型未必是预期类型。例如,如果不添加注解,balance 的类型将是 Nat,从而无法为其赋整数值。

  • 为类中的所有公共字段添加类型注解。

    class C(init_ : Nat) {
      public let init : Nat = init_;
      public var count : Nat = 0;
    }
    
  • 当函数的返回类型为 () 时,省略返回类型注解。

    func twiceF() { f(); f() };  // 无需写 ": ()"
    
  • 函数作为参数传递时,省略其类型注解。

    Array.map<Nat, Nat>(func n {n + 1}, a);
    
  • 对涉及 Nat 或 Int 以外数值类型的定义添加类型注解,以消除算术运算符和常量之间的重载歧义。

    let mask : Nat32 = 0xfc03_ff00;
    let offset : Nat32 = size + 1;
    
选择类型
  • 对于任何不能为负数的整数值,请使用 Nat。

  • 仅在存储大量值且空间占用很重要、位操作需要将数字按底层方式解释为位向量,或需要匹配外部要求(例如其他 canister)指定的类型时,才使用固定宽度的 NatN 或 IntN。

  • 避免过多使用选项类型,从而避免过多使用 null。

    尽可能将它们的使用范围限制到最小。排除 null 情况,并尽可能使用非选项类型。

  • 如果组件超过 2 或 3 个,请考虑使用记录而非元组。记录只是带有命名字段的简单对象。

    请注意,记录类型不必声明,可以直接使用。

      func nodeInfo(node : Node) : {parent : Node; left : Node; right : Node} { ... }
    
  • 请考虑使用变体而非 Bool 来表示二元选择。

    请注意,变体类型不必声明,可以直接使用。

    func capitalization(word : Text) : {#upper; #lower} { ... }
    
  • 对于主要用于更改状态或产生其他副作用的函数,尽可能使用返回类型 ()。

    class Set<X>() {
      public func add(x : X) { ... };
      public func remove(x : X) { ... };
      ...
    };
    
  • 对于参数列表较长的情况,请考虑使用记录(仅包含数据的对象)作为参数。

    func process({seed : Float; delta : Float; data : [Record]; config : Config}) : Thing {
      ...
    };
    
    process{config = Config(); data = read(); delta = 0.01; seed = 1.0};
    

    理由:这样可以表达命名参数。调用时可以自由调整参数顺序,也能避免调用者意外地按错误顺序传递参数。

  • 高阶函数(例如接受回调参数的函数)应将函数参数放在最后。

    理由:这样调用代码更易读;而且在没有柯里化的情况下,把函数放在最前面并无意义,尽管在函数式语言中通常会这样做。

  • 不要使用 -1 之类的哨兵值来表示无效值。

    请改用选项类型。

    func lookup(x : key) : ?Nat { ... }
    
  • 在 Motoko 中,数据默认不可变,除非明确声明为可变。

    请谨慎使用可变性类型和定义(var),仅在必要时使用。

    理由:可变数据无法在 actor 之间传递或共享。它更容易出错,也更难进行形式化推理,尤其是在涉及并发时。

功能

语句
  • 遍历数值范围或容器时,使用 for 循环,而不是 while 循环。

    for (i in Iter.range(1, 10)) { ... };
    for (x in array.values()) { ... };
    

    理由:for 循环不容易出错,也更易于阅读。

  • 适当时,将 if 或 switch 用作表达式。

    func abs(i : Int) : Int { if (i < 0) -i else i };
    
    let delta = switch mode { case (#up) +1; case (#dn) -1 };
    
  • Motoko 要求代码块中的所有表达式都具有 () 类型,以避免意外丢弃结果。

    使用 ignore 显式丢弃结果。不需要时,不要使用 ignore。

    ignore async f();  // 触发计算
    
  • Motoko 允许省略函数末尾的 return,因为代码块的值就是其最后一个表达式的值。

    当函数较短且采用“函数式”风格时使用这种写法,也就是说,函数不包含复杂的控制流或副作用。

    如果函数中包含其他 return 语句或命令式控制流,则在末尾显式使用 return。

    func add(i : Nat, j : Nat) : Nat { i + j };
    
    func foo(a : Float, b : Float) : Float {
      let c = a*a + b*b;
      c + 2*c*c;
    };
    
    func gcd(i : Nat, j : Nat) : Nat {
      if (j == 0) i else gcd(j, i % j);
    };
    
    func gcd2(i : Nat, j : Nat) : Nat {
      var a = i;
      var b = j;
      while (b > 0) {
        let c = a;
        a := b;
        b := c % b;
      };
      return a;
    };
    
对象和记录
  • 将对象用作简单记录(即没有私有状态和方法的数据结构)时,使用简写对象语法 {x1 = e1; …​ ; xN = eN}。

  • 创建单例对象时,使用 object。

  • 尽可能将对象限制为记录。

    理由:只有记录才能用作消息参数或结果,也才能存储在稳定变量中。创建带方法的对象以及在内存中表示它们的成本也更高。

  • 完整对象仅用于封装状态或行为。

类
  • 使用 class 创建多个形状相同的对象。

  • 类名应体现其概念功能,而不是实现方式;只有在需要区分同一概念的多种不同实现时才例外。例如,OrderedMap 与 HashMap)。

  • 类既是类型定义,也是对象的工厂函数。

    除非确实需要这两种作用,否则不要使用类;其他情况下请使用普通类型别名或返回对象的函数。

  • 不要过度使用类。

    适当时,使用定义普通类型及其函数的模块。类仅用于封装状态或行为。

    理由:与简单记录类型搭配独立函数相比,带方法的对象有其缺点(见上文)。

  • 如果类的值需要可发送(共享),则该类需要提供一对 share/unshare 方法,用于转换为可共享表示形式(例如记录)或从中转换回来。

    :::note

    对于不可变类,将 unshare 实现为某种静态函数可能看起来更自然。不过,即使对于不可变类,它也可能依赖构造函数参数(例如排序函数),因此采用 Map(compareInt).unshare(x) 这样的模式似乎更合适。

    :::

  • 目前,避免为类添加过多方法,因为这会带来较高开销。

    将方法限制在足够少的一组规范方法中;对于那些不太重要且可基于这些规范方法实现的方法,应将其作为外层模块中的函数。

  • 对于“静态”类或方法,使用模块。

模块
  • 使用 module 对定义(包括类型)进行分组,并为其创建命名空间。

  • 在适用的情况下,根据模块实现或为其提供函数的主要类型或类来命名模块。

  • 每个模块仅限于一个主要概念、类型或类,或一组紧密关联的概念、类型或类。

评论

支持 Markdown