在 GitHub 上编辑此页面

可选大括号

Scala 3 对缩进强制执行一些规则,并允许某些大括号 {...} 的出现是可选的

  • 首先,一些缩进错误的程序会被标记为警告。
  • 其次,某些大括号 {...} 的出现变为可选。通常,规则是添加一对可选大括号不会改变缩进良好的程序的含义。

可以使用编译器标志 -no-indent 关闭这些更改。

缩进规则

编译器对缩进良好的程序强制执行两条规则,将违规行为标记为警告。

  1. 在大括号定界区域中,不允许任何语句从新行的第一个语句左侧开始,该语句在大括号后开始。

    此规则有助于查找缺少的闭合大括号。它可以防止出现以下错误

    if (x < 0) {
      println(1)
      println(2)
    
    println("done")  // error: indented too far to the left
    
  2. 如果关闭了重要缩进(即在 Scala 2 模式下或在 -no-indent 下),并且我们处于表达式的缩进子部分的开头,并且缩进部分以新行结尾,则下一个语句必须从比子部分缩进宽度更小的缩进开始。这可以防止忘记开括号的错误,如下所示

    if (x < 0)
      println(1)
      println(2)   // error: missing `{`
    

这些规则仍然留下了很多程序缩进方式的余地。例如,它们不对表达式中的缩进施加任何限制,也不要求缩进块的所有语句完全对齐。

这些规则通常有助于查明与缺少左花括号或右花括号相关的错误的根本原因。这些错误通常很难诊断,尤其是在大型程序中。

可选花括号

编译器将在某些换行符处插入 <indent><outdent> 令牌。在语法上,成对的 <indent><outdent> 令牌与成对的花括号 {} 具有相同的效果。

该算法使用一个栈 IW 来存储之前遇到的缩进宽度。栈最初包含一个元素,其缩进宽度为零。当前缩进宽度是栈顶部的缩进宽度。

有两条规则

  1. 如果满足以下条件,则在换行符处插入 <indent>

    • 缩进区域可以在源中的当前位置开始,并且
    • 下一行的第一个令牌的缩进宽度严格大于当前缩进宽度

    缩进区域可以开始

    • extension 的前导参数之后,或者

    • 在给定实例中的 with 之后,或者

    • 在模板正文开始处的 : 之后(请参阅下面对 <colon> 的讨论),或者

    • 在以下令牌之一之后

      =  =>  ?=>  <-  catch  do  else  finally  for
      if  match  return  then  throw  try  while  yield
      
    • 在旧式 ifwhile 中条件的闭合 ) 之后。

    • 在没有 do 的旧式 for 循环的枚举的闭合 )} 之后。

    如果插入 <indent>,则下一行的令牌的缩进宽度将推送到 IW,使其成为新的当前缩进宽度。

  2. 如果满足以下条件,则在换行符处插入 <outdent>

    • 下一行的第一个令牌的缩进宽度严格小于当前缩进宽度,并且
    • 前一行的最后一个令牌不是以下令牌之一,这些令牌表示前一条语句继续
      then  else  do  catch  finally  yield  match
      
    • 如果下一行的第一个令牌是 前导中缀运算符。然后其缩进宽度小于当前缩进宽度,并且它要么匹配先前的缩进宽度,要么也小于封闭的缩进宽度。

    如果插入 <outdent>,则从 IW 中弹出顶部元素。如果下一行的令牌的缩进宽度仍然小于新的当前缩进宽度,则步骤 (2) 重复。因此,可以连续插入多个 <outdent> 令牌。

    以下两条附加规则支持解析具有临时布局的旧代码。它们可能会在未来的语言版本中被废弃

    • 如果一个以 <indent> 开头的语句序列的下一个标记关闭了一个缩进区域,则也会插入一个 <outdent>,即 thenelsedocatchfinallyyield})]case 之一。

    • 如果缩进区域本身用括号括起来,则最终在以 <indent> 开头的语句序列后面的逗号前插入一个 <outdent>

如果 <outdent> 后面的标记的缩进宽度与封闭缩进区域中某一先前行的缩进不匹配,则通常会报错。例如,以下内容将被拒绝。

if x < 0 then
    -x
  else   // error: `else` does not align correctly
    x

但是,此规则有一个例外:如果下一行以“.”开头并且缩进宽度与两个相邻区域的缩进宽度相差超过一个空格,则该行被接受。例如,以下内容是可以接受的

xs.map: x =>
    x + 1
  .filter: x =>
    x > 0

此处,以 .filter 开头的行没有与先前行匹配的缩进级别,但它仍然被接受,因为它以“.”开头,并且与关闭区域和下一个外部区域的缩进级别相差至少两个空格。

缩进标记仅插入到也推断出换行语句分隔符的区域中:在顶级,在大括号 {...} 内,但不在小括号 (...)、模式或类型内。

注意:上述前导中缀运算符的规则是为了确保

one
  + two.match
      case 1 => b
      case 2 => c
  + three

被解析为 one + (two.match ...) + three。此外,

if x then
    a
  + b
  + c
else d

被解析为 if x then a + b + c else d

模板主体周围的可选大括号

Scala 语法将类、特征或对象的定义称为模板主体,这些定义通常用大括号括起来。还可以通过以下规则省略模板主体周围的大括号。

模板主体还可以由冒号后跟一个或多个缩进语句组成。为此,我们引入了一个新的 <colon> 标记,它读作标准冒号“:”,但在上下文中自由语法合法的情况下生成,但仅当前一个标记是字母数字标识符、反引号标识符或标记 thissuper)] 之一。

缩进区域可以在 <colon> 后开始。模板主体可以括在大括号中,也可以以 <colon> <indent> 开头,以 <outdent> 结尾。枚举主体、类型细化和包含嵌套定义的本地包也适用类似的规则。

有了这些新规则,以下结构都是有效的

trait A:
  def f: Int

class C(x: Int) extends A:
  def f = x

object O:
  def f = 3

enum Color:
  case Red, Green, Blue

new A:
  def f = 3

package p:
  def a = 1

package q:
  def b = 2

在每种情况下,行尾的 : 可以用一对大括号替换,而不会改变含义,大括号括起以下缩进的定义。

允许此操作的语法更改如下

为任意标记或非终结符序列 TS 定义

:<<< TS >>>   ::=   ‘{’ TS ‘}’
                |   <colon> <indent" TS <outdent>

然后语法更改如下

TemplateBody      ::=  :<<< [SelfType] TemplateStat {semi TemplateStat} >>>
EnumBody          ::=  :<<< [SelfType] EnumStat {semi EnumStat} >>>
Refinement        ::=  :<<< [RefineDcl] {semi [RefineDcl]} >>>
Packaging         ::=  ‘package’ QualId :<<< TopStats >>>

方法参数的可选大括号

从 Scala 3.3 开始,在预期函数参数的位置也识别 <colon> 标记。示例

times(10):
  println("ah")
  println("ha")

credentials `++`:
  val file = Path.userHome / ".credentials"
  if file.exists
  then Seq(Credentials(file))
  else Seq()

xs.map:
  x =>
    val y = x - 1
    y * y

此外,在这些设置中,: 还可以后跟同一行上的 lambda 的参数部分和箭头。因此,最后一个示例可以压缩为

xs.map: x =>
  val y = x - 1
  y * y

以下内容也将合法

xs.foldLeft(0): (x, y) =>
  x + y

参数周围可选大括号的语法更改如下。

SimpleExpr       ::=  ...
                   |  SimpleExpr ColonArgument
InfixExpr        ::=  ...
                   |  InfixExpr id ColonArgument
ColonArgument    ::=  colon [LambdaStart]
                      indent (CaseClauses | Block) outdent
LambdaStart      ::=  FunParams (‘=>’ | ‘?=>’)
                   |  HkTypeParamClause ‘=>’

空格与制表符

缩进前缀可以由空格和/或制表符组成。缩进宽度是缩进前缀本身,按字符串前缀关系排序。因此,例如“2 个制表符,后跟 4 个空格”严格小于“2 个制表符,后跟 5 个空格”,但“2 个制表符,后跟 4 个空格”与“6 个制表符”或“4 个空格,后跟 2 个制表符”不可比。如果某一行的缩进宽度与该点当前区域的缩进宽度不可比,则会出错。为避免此类错误,最好不要在同一源文件中混合使用空格和制表符。

缩进和大括号

缩进可以与大括号 {...}、方括号 [...] 和圆括号 (...) 自由混合。要解释此类区域内的缩进,请应用以下规则。

  1. 用大括号括起来的多行区域的假定缩进宽度是开大括号后开始新行的第一个标记的缩进宽度。

  2. 方括号或圆括号内多行区域的假定缩进宽度为

    • 如果开方括号或圆括号位于行尾,则为其后标记的缩进宽度,
    • 否则,为封闭区域的缩进宽度。
  3. 遇到闭合大括号 }、方括号 ] 或圆括号 ) 时,将插入尽可能多的 <outdent> 标记来关闭所有打开的嵌套缩进区域。

例如,考虑

{
  val x = 4
  f(x: Int, y =>
    x * (
      y + 1
    ) +
    (x +
    x)
  )
}
  • 此处,由大括号括起来区域的缩进宽度为 2(即以 val 开头的语句的缩进宽度)。
  • 紧跟 f 的括号中区域的缩进宽度也是 2,因为左括号不在行的末尾。
  • 围绕 y + 1 的括号中区域的缩进宽度为 6(即 y + 1 的缩进宽度)。
  • 最后,以 (x 开头的最后一个括号中区域的缩进宽度为 4(即紧跟 => 的缩进区域的缩进宽度)。

Case 子句的特殊处理

match 表达式和 catch 子句的缩进规则经过如下细化

  • 如果紧跟的 case 出现在与 match 本身当前缩进宽度相同的缩进宽度处,则在 matchcatch 后打开一个缩进区域。
  • 在这种情况下,缩进区域在该相同缩进宽度处的第一个不是 case 的标记处关闭,或在缩进宽度更小的任何标记处关闭,以先出现的为准。

这些规则允许编写 match 表达式,其中 case 本身不缩进,如下例所示

x match
case 1 => print("I")
case 2 => print("II")
case 3 => print("III")
case 4 => print("IV")
case 5 => print("V")

println(".")

使用缩进来表示语句延续

在某些情况下,使用缩进来决定在两行之间插入一个虚拟分号还是将它们视为一个语句。如果第二行相对于第一行缩进更多,并且第二行以 "("、"[" 或 "{" 开头,或者第一行以 return 结尾,则会取消虚拟分号插入。示例

f(x + 1)
  (2, 3)        // equivalent to  `f(x + 1)(2, 3)`

g(x + 1)
(2, 3)          // equivalent to  `g(x + 1); (2, 3)`

h(x + 1)
  {}            // equivalent to  `h(x + 1){}`

i(x + 1)
{}              // equivalent to  `i(x + 1); {}`

if x < 0 then return
  a + b         // equivalent to  `if x < 0 then return a + b`

if x < 0 then return
println(a + b)  // equivalent to  `if x < 0 then return; println(a + b)`

在 Scala 2 中,以 "{" 开头的行总是延续前一行的函数调用,而不管缩进如何,而在所有其他情况下都会插入一个虚拟分号。在源 -no-indent-source 3.0-migration 下保留 Scala-2 行为。

结束标记

基于缩进的语法比其他约定具有许多优势。但是,一个可能的问题是,它很难辨别何时一个大的缩进区域结束,因为没有特定的标记来描绘结束。大括号并没有好多少,因为大括号本身也不包含有关哪个区域被关闭的信息。

为了解决这个问题,Scala 3 提供了一个可选的 end 标记。示例

def largeMethod(...) =
  ...
  if ... then ...
  else
    ... // a large block
  end if
  ... // more code
end largeMethod

一个 end 标记由标识符 end 和后续说明符标记组成,它们共同构成了一行的所有标记。可能的说明符标记是标识符或以下关键字之一

if   while    for    match    try    new    this    val   given

语句序列中允许使用结束标记。结束标记的说明符标记 s 必须与它前面的语句相对应。这意味着

  • 如果语句定义了一个成员 x,则 s 必须是相同的标识符 x
  • 如果语句定义了一个构造函数,则 s 必须是 this
  • 如果语句定义了一个匿名给定,则 s 必须是 given
  • 如果语句定义了一个匿名扩展,则 s 必须是 extension
  • 如果语句定义了一个匿名类,则 s 必须是 new
  • 如果语句是一个绑定模式的 val 定义,则 s 必须是 val
  • 如果语句是一个引用包 p 的包子句,则 s 必须是相同的标识符 p
  • 如果语句是一个 ifwhilefortrymatch 语句,则 s 必须是相同的标记。

例如,以下结束标记都是合法的

package p1.p2:

  abstract class C():

    def this(x: Int) =
      this()
      if x > 0 then
        val a :: b =
          x :: Nil
        end val
        var y =
          x
        end y
        while y > 0 do
          println(y)
          y -= 1
        end while
        try
          x match
            case 0 => println("0")
            case _ =>
          end match
        finally
          println("done")
        end try
      end if
    end this

    def f: String
  end C

  object C:
    given C =
      new C:
        def f = "!"
        end f
      end new
    end given
  end C

  extension (x: C)
    def ff: String = x.f ++ x.f
  end extension

end p2

何时使用结束标记

建议将 end 标记用于缩进区域的范围“一目了然”时无法立即显现的代码。人们对这意味着什么会有不同的偏好,但人们仍然可以根据经验给出一些指导原则。如果满足以下条件,则结束标记是有意义的

  • 构造包含空行,或者
  • 构造很长,比如 15-20 行或更多行,
  • 构造以很深的缩进结束,比如 4 个缩进级别或更多。

如果这些标准都不适用,通常最好不使用结束标记,因为代码将同样清晰且更简洁。如果有多个结束区域满足上述标准之一,我们通常只需要为最外层封闭区域使用结束标记。因此,通常最好避免像上面示例中那样的结束标记级联。

语法

EndMarker         ::=  ‘end’ EndMarkerTag    -- when followed by EOL
EndMarkerTag      ::=  id | ‘if’ | ‘while’ | ‘for’ | ‘match’ | ‘try’
                    |  ‘new’ | ‘this’ | ‘given’ | ‘extension’ | ‘val’
BlockStat         ::=  ... | EndMarker
TemplateStat      ::=  ... | EndMarker
TopStat           ::=  ... | EndMarker

示例

这是一个使用缩进的代码示例(有点元循环)。它提供了如上所定义的缩进宽度的具体表示,以及用于构造和比较缩进宽度的有效操作。

enum IndentWidth:
  case Run(ch: Char, n: Int)
  case Conc(l: IndentWidth, r: Run)

  def <= (that: IndentWidth): Boolean = this match
    case Run(ch1, n1) =>
      that match
        case Run(ch2, n2) => n1 <= n2 && (ch1 == ch2 || n1 == 0)
        case Conc(l, r)   => this <= l
    case Conc(l1, r1) =>
      that match
        case Conc(l2, r2) => l1 == l2 && r1 <= r2
        case _            => false

  def < (that: IndentWidth): Boolean =
    this <= that && !(that <= this)

  override def toString: String =
    this match
      case Run(ch, n) =>
        val kind = ch match
          case ' '  => "space"
          case '\t' => "tab"
          case _    => s"'$ch'-character"
        val suffix = if n == 1 then "" else "s"
        s"$n $kind$suffix"
      case Conc(l, r) =>
        s"$l, $r"

object IndentWidth:
  private inline val MaxCached = 40

  private val spaces = IArray.tabulate(MaxCached + 1)(new Run(' ', _))
  private val tabs = IArray.tabulate(MaxCached + 1)(new Run('\t', _))

  def Run(ch: Char, n: Int): Run =
    if n <= MaxCached && ch == ' ' then
      spaces(n)
    else if n <= MaxCached && ch == '\t' then
      tabs(n)
    else
      new Run(ch, n)
  end Run

  val Zero = Run(' ', 0)
end IndentWidth

设置和重写

默认情况下启用显着缩进。可以通过提供以下任何选项来关闭它:-no-indent-old-syntax-source 3.0-migration。如果缩进被关闭,它仍然会检查缩进是否符合由大括号定义的逻辑程序结构。如果不是这种情况,编译器会发出警告。

Scala 3 编译器可以将源代码重写为缩进代码,反之亦然。当使用选项 -rewrite -indent 调用时,它将在可能的情况下将大括号重写为缩进区域。当使用选项 -rewrite -no-indent 调用时,它将反向重写,为缩进区域插入大括号。-indent 选项仅适用于新式语法。因此,要从旧式语法转到新式缩进代码,必须调用编译器两次,第一次使用选项 -rewrite -new-syntax,然后再次使用选项 -rewrite -indent。要反向进行,从缩进代码到旧式语法,则为 -rewrite -no-indent,然后为 -rewrite -old-syntax