선택적 중괄호

선택적 중괄호 (Optional Braces)

Scala 3은 들여쓰기에 몇 가지 규칙을 적용하고, {...} 중괄호가 일부 자리에서 생략될 수 있게 해요. 잘 들여쓴 프로그램이라면 중괄호를 추가해도 의미가 바뀌지 않는다는 게 핵심 원칙이에요. 이 기능이 어떤 규칙으로 동작하는지, 왜 이런 설계를 택했는지 살펴볼게요.

출처: Scala 3 Reference

본문

Scala 3은 들여쓰기에 몇 가지 규칙을 강제하고, 일부 {...} 중괄호의 발생을 선택적으로 만들 수 있게 해요:

  • 첫째, 잘못 들여쓴 일부 프로그램은 경고로 표시돼요.
  • 둘째, 일부 {...} 중괄호의 발생이 선택적으로 만들어져요. 일반적으로 규칙은 선택적 중괄호 한 쌍을 추가해도 잘 들여쓴 프로그램의 의미가 바뀌지 않는다는 것이에요.

이 변경들은 컴파일러 플래그 -no-indent로 끌 수 있어요.

들여쓰기 규칙 (Indentation Rules)

컴파일러는 잘 들여쓴 프로그램에 대해 두 규칙을 강제하고, 위반을 경고로 표시해요.

  • 중괄호로 구분된 영역에서, 여는 중괄호 다음에 새 줄을 시작하는 첫 문장의 왼쪽으로 문장이 시작해서는 안 돼요.

    이 규칙은 닫는 중괄호가 빠진 것을 찾는 데 도움이 돼요. 다음과 같은 오류를 막아 주죠:

    if (x < 0) {
      println(1)
      println(2)
    
    println("done")  // error: indented too far to the left
    
  • 유의미한 들여쓰기(significant indentation)가 꺼져 있을 때(즉 Scala 2 모드나 -no-indent 아래) 표현식의 들여쓴 하위 부분의 시작에 있고, 들여쓴 부분이 새 줄로 끝난다면, 다음 문장은 그 하위 부분의 들여쓰기 너비보다 작은 들여쓰기 너비에서 시작해야 해요. 이는 여는 중괄호를 잊은 오류를 막아 줍니다. 이런 경우죠:

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

이 규칙들은 프로그래밍을 어떻게 들여쓸지에 대해 여전히 많은 여지를 남겨요. 예를 들어 표현식 내부의 들여쓰기에는 어떤 제한도 두지 않고, 들여쓰기 블록의 모든 문장이 정확히 정렬될 것을 요구하지도 않아요.

이 규칙들은 여는 중괄호나 닫는 중괄호가 빠진 것과 관련된 오류의 근본 원인을 짚어내는 데 일반적으로 도움이 돼요. 이런 오류는 특히 큰 프로그램에서 진단하기 꽤 어렵죠.

선택적 중괄호 (Optional Braces)

컴파일러는 특정 줄바꿈에 <indent> 또는 <outdent> 토큰을 삽입해요. 문법적으로 <indent><outdent> 토큰 쌍은 중괄호 {}의 쌍과 같은 효과를 가져요.

이 알고리즘은 이전에 만난 들여쓰기 너비들의 스택 IW를 사용해요. 스택은 처음에 들여쓰기 너비가 0인 단일 요소를 담아요. 현재 들여쓰기 너비는 스택 맨 위의 들여쓰기 너비예요.

두 가지 규칙이 있어요:

  • 다음과 같은 경우 줄바꿈에서 <indent>가 삽입돼요:

    • 들여쓰기 영역이 소스의 현재 위치에서 시작할 수 있고, 그리고
    • 다음 줄의 첫 토큰이 현재 들여쓰기 너비보다 엄격히 큰 들여쓰기 너비를 가질 때.

    들여쓰기 영역은 다음과 같은 경우 시작할 수 있어요:

    • 확장의 선행 파라미터 뒤, 또는
    • given 인스턴스의 with 뒤, 또는
    • 템플릿 본문 시작의 : 뒤(아래 <colon> 논의 참조), 또는
    • 다음 토큰 중 하나 뒤:
      =  =>  ?=>  <-  catch  do  else  finally  for
      if  match  return  then  throw  try  while  yield
      
    • 옛 스타일 if 또는 while의 조건 닫는 ) 뒤.
    • do 없는 옛 스타일 for 루프의 열거(enumeration) 닫는 ) 또는 } 뒤.

    <indent>가 삽입되면, 다음 줄 토큰의 들여쓰기 너비가 IW에 푸시되어 그것이 새 현재 들여쓰기 너비가 돼요.

  • 다음과 같은 경우 줄바꿈에서 <outdent>가 삽입돼요:

    • 다음 줄의 첫 토큰이 현재 들여쓰기 너비보다 엄격히 작은 들여쓰기 너비를 가질 때, 그리고
    • 이전 줄의 마지막 토큰이 이전 문장이 계속된다는 것을 나타내는 다음 토큰 중 하나가 아닐 때:
      then  else  do  catch  finally  yield  match
      
    • 다음 줄의 첫 토큰이 선행 중위 연산자라면, 그 들여쓰기 너비가 현재 들여쓰기 너비보다 작고, 이전 들여쓰기 너비와 일치하거나 둘러싸는 들여쓰기 너비보다도 작을 때.

    <outdent>가 삽입되면, 맨 위 요소가 IW에서 팝돼요. 다음 줄 토큰의 들여쓰기 너비가 여전히 새 현재 들여쓰기 너비보다 작으면, 단계 (2)가 반복돼요. 그래서 여러 <outdent> 토큰이 연속으로 삽입될 수 있어요.

다음 두 추가 규칙은 임시 레이아웃(ad-hoc layout)을 가진 레거시 코드의 파싱을 지원해요. 이것들은 미래 언어 버전에서 철회될 수 있어요:

  • <indent>로 시작하는 문장 시퀀스 다음에 오는 토큰이 들여쓰기 영역을 닫는다면, 즉 then, else, do, catch, finally, yield, }, ), ] 또는 case 중 하나라면 <outdent>도 삽입돼요.
  • 들여쓰기된 영역 자체가 괄호로 둘러싸여 있다면, <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로 파싱되도록 보장하기 위한 거예요.

템플릿 본문 주변의 선택적 중괄호 (Optional Braces Around Template Bodies)

Scala 문법은 보통 중괄호로 둘러싸인 클래스, trait, 객체의 정의에 **템플릿 본문(template body)**이라는 용어를 사용해요. 템플릿 본문 주변의 중괄호는 다음 규칙으로 생략될 수도 있어요.

템플릿 본문은 대안으로 콜론 뒤에 하나 이상의 들여쓴 문장으로 구성될 수 있어요. 이를 위해 우리는 새 <colon> 토큰을 도입해요. 이것은 표준 콜론 ":"으로 읽히지만, 컨텍스트 자유 문법에 따라 <colon>이 합법적인 곳에서, 그리고 이전 토큰이 영숫자 식별자, 백틱 식별자, 또는 this, super, ")", "]" 중 하나일 때만 그것 대신 생성돼요.

들여쓰기 영역은 <colon> 뒤에 시작할 수 있어요. 템플릿 본문은 중괄호로 둘러싸이거나, <colon> <indent>로 시작해 <outdent>로 끝날 수 있어요. enum 본문, 타입 리파인먼트, 중첩 정의를 포함하는 지역 패키지에도 유사한 규칙이 적용돼요.

이 새 규칙들로 다음 구조들은 모두 유효해요:

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 >>>

메서드 인자의 선택적 중괄호 (Optional Braces for Method Arguments)

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

게다가 이런 설정의 :는 같은 줄에 이어서 람다의 파라미터 부분과 화살표가 올 수도 있어요. 그래서 마지막 예시를 이렇게 줄일 수 있어요:

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 ‘=>’

스페이스 vs 탭 (Spaces vs Tabs)

들여쓰기 접두사는 스페이스 및/또는 탭으로 구성될 수 있어요. 들여쓰기 너비는 들여쓰기 접두사 그 자체로, 문자열 접두사 관계로 정렬돼요. 그래서 예를 들어 "탭 2개, 뒤이어 스페이스 4개"는 "탭 2개, 뒤이어 스페이스 5개"보다 엄격히 작지만, "탭 6개"나 "스페이스 4개, 뒤이어 탭 2개"와는 비교 불가능해요. 어떤 줄의 들여쓰기 너비가 그 지점에서 현재인 영역의 들여쓰기 너비와 비교 불가능하면 오류예요. 이런 오류를 피하려면 같은 소스 파일에서 스페이스와 탭을 섞지 않는 것이 좋아요.

들여쓰기와 중괄호 (Indentation and Braces)

들여쓰기는 중괄호 {...}, 대괄호 [...], 괄호 (...)와 자유롭게 섞일 수 있어요. 그런 영역 안의 들여쓰기를 해석할 때 다음 규칙이 적용돼요.

  • 중괄호로 둘러싸인 여러 줄 영역의 가정된 들여쓰기 너비는 여는 중괄호 다음에 새 줄을 시작하는 첫 토큰의 들여쓰기 너비예요.
  • 대괄호나 괄호 안의 여러 줄 영역의 가정된 들여쓰기 너비는:
    • 여는 대괄호나 괄호가 줄 끝에 있다면, 그것을 따르는 토큰의 들여쓰기 너비,
    • 그렇지 않다면, 둘러싸는 영역의 들여쓰기 너비예요.
  • 닫는 중괄호 }, 대괄호 ] 또는 괄호 )를 만나면, 열린 모든 중첩 들여쓰기 영역을 닫는 데 필요한 만큼 많은 <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 절의 특별한 취급 (Special Treatment of Case Clauses)

match 표현식과 catch 절의 들여쓰기 규칙은 다음과 같이 리파인드돼요:

  • match 또는 catch 뒤에, 뒤따르는 case가 match 자체에 현재인 들여쓰기 너비로 나타나도 들여쓰기 영역이 열려요.
  • 그 경우, 들여쓰기 영역은 case가 아닌 같은 들여쓰기 너비의 첫 토큰에서, 또는 더 작은 들여쓰기 너비의 어떤 토큰에서, 둘 중 먼저 오는 것에서 닫혀요.

이 규칙들 덕분에 아래 예시처럼 case가 그 자체로 들여쓰기되지 않는 match 표현식을 쓸 수 있어요:

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

println(".")

문장 연속을 알리는 들여쓰기 (Using Indentation to Signal Statement Continuation)

들여쓰기는 어떤 상황에서 두 연속 줄 사이에 가상 세미콜론을 삽입할지, 아니면 그것들을 한 문장으로 취급할지 결정하는 데 쓰여요. 두 번째 줄이 첫 번째 줄에 비해 더 많이 들여쓰여 있고, 두 번째 줄이 "(", "[", 또는 "{"로 시작하거나 첫 번째 줄이 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에서는 들여쓰기와 무관하게 "{"로 시작하는 줄이 항상 앞 줄의 함수 호출을 계속하는 반면, 다른 모든 경우에는 가상 세미콜론이 삽입돼요. Scala-2 동작은 -no-indent 또는 -source 3.0-migration 아래에서 유지됩니다.

종료 마커 (The End Marker)

들여쓰기 기반 문법은 다른 관례에 비해 많은 장점이 있어요. 하지만 한 가지 가능한 문제는 큰 들여쓰기 영역이 언제 끝나는지 알아차리기 어렵다는 점이에요. 끝을 구분하는 특정 토큰이 없으니까요. 중괄호도 크게 나을 게 없어요. 중괄호 하나만으로는 어떤 영역이 닫혔는지에 대한 정보를 담지 않으니까요.

이 문제를 해결하기 위해 Scala 3은 선택적 **종료 마커(end marker)**를 제공해요. 예시:

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

종료 마커는 식별자 end와 함께, 함께 한 줄의 모든 토큰을 구성하는 뒤따르는 지정자 토큰으로 이루어져요. 가능한 지정자 토큰은 식별자 또는 다음 키워드 중 하나예요:

if   while    for    match    try    new    this    val   given

종료 마커는 문장 시퀀스에서 허용돼요. 종료 마커의 지정자 토큰 s는 앞에 오는 문장과 대응해야 해요. 즉:

  • 문장이 멤버 x를 정의하면 s는 같은 식별자 x여야 해요.
  • 문장이 생성자를 정의하면 sthis여야 해요.
  • 문장이 익명 given을 정의하면 sgiven이어야 해요.
  • 문장이 익명 확장을 정의하면 sextension이어야 해요.
  • 문장이 익명 클래스를 정의하면 snew여야 해요.
  • 문장이 패턴을 묶는 val 정의이면 sval이어야 해요.
  • 문장이 패키지 p를 참조하는 패키지 절이면 s는 같은 식별자 p여야 해요.
  • 문장이 if, while, for, try, 또는 match 문장이면 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

종료 마커를 언제 쓸까 (When to Use End Markers)

들여쓰기 영역의 범위가 "한눈에" 즉시 명확하지 않은 코드에는 종료 마커를 쓰는 것이 권장돼요. 이것이 무엇을 의미하는지는 사람마다 선호가 다르지만, 경험에서 나온 지침을 줄 수는 있어요. 종료 마커는 다음의 경우에 의미가 있어요:

  • 구조가 빈 줄을 포함할 때, 또는
  • 구조가 길 때, 말하자면 15-20줄 이상일 때, 또는
  • 구조가 많이 들여써서 끝날 때, 말하자면 4개 이상의 들여쓰기 수준으로 끝날 때.

이 기준 중 어느 것도 해당하지 않으면 종료 마커를 쓰지 않는 게 종종 더 나아요. 코드가 똑같이 명확하면서 더 간결하니까요. 위 기준 중 하나를 만족하는 여러 종료 영역이 있다면, 보통 가장 바깥쪽 닫힌 영역에만 종료 마커가 필요해요. 그래서 위 예시 같은 종료 마커의 캐스케이드는 보통 피하는 게 좋아요.

문법 (Syntax)

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

예시 (Example)

다음은 들여쓰기를 사용하는 (다소 메타 순환적인) 코드 예시예요. 위에서 정의한 들여쓰기 너비의 구체적인 표현을, 들여쓰기 너비를 구성하고 비교하는 효율적인 연산과 함께 제공해 줍니다.

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

설정과 재작성 (Settings and Rewrites)

유의미한 들여쓰기는 기본으로 켜져 있어요. -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 순으로 호출하면 돼요.

더 알아보기 (Learn more)