제어 구조
제어 구조 (Control Structures)
Scala에는 프로그래밍 언어에서 기대하는 제어 구조가 다 들어 있어요. if/then/else, for 루프, while 루프, try/catch/finally가 그것이죠. 거기에 프로그래밍 배경에 따라 처음 보는 강력한 구조 두 가지도 함께 제공해요.
for표현식 (일명for컴프리헨션)match표현식
이번 장에서는 이 모두를 하나씩 다뤄 볼게요.
출처: Scala 3 Book
본문
if/then/else 구조 (The if/then/else construct)
한 줄짜리 Scala if 문은 이렇게 생겼어요.
if x == 1 then println(x)
if 비교 뒤에 여러 줄의 코드를 실행해야 한다면 이 구문을 쓰면 돼요.
if x == 1 then
println("x is 1, as you can see:")
println(x)
if/else 구문은 이렇게 생겼어요.
if x == 1 then
println("x is 1, as you can see:")
println(x)
else
println("x was not 1")
그리고 if/else if/else 구문은 이렇게 생겼어요.
if x < 0 then
println("negative")
else if x == 0 then
println("zero")
else
println("positive")
end if 문
** 이 기능은 Scala 3에서 새로 나온 것이고, Scala 2에서는 지원되지 않아요.
원한다면 각 표현식 끝에 end if 문을 선택적으로 붙일 수 있어요.
if x == 1 then
println("x is 1, as you can see:")
println(x)
end if
if/else 표현식은 항상 결과를 반환해요
if/else 비교는 *표현식(expression)*을 이룬다는 점을 기억하세요. 즉 변수에 할당할 수 있는 값을 반환한다는 뜻이에요. 덕분에 별도의 삼항 연산자(ternary operator)가 필요 없어요.
val minValue = if a < b then a else b
값을 반환하기 때문에 if/else 표현식을 메서드의 본문으로도 쓸 수 있어요.
def compare(a: Int, b: Int): Int =
if a < b then
-1
else if a == b then
0
else
1
여담: 표현식 지향 프로그래밍 (Expression-oriented programming)
프로그래밍 전반에 대한 짧은 이야기인데, 쓰는 모든 표현식이 값을 반환하는 스타일을 *표현식 지향 프로그래밍(expression-oriented programming, EOP)*이라고 불러요. 예를 들어 이건 표현식이에요.
val minValue = if a < b then a else b
반대로 값을 반환하지 않는 코드 줄은 *문(statement)*이라고 하고, 그 *부수 효과(side-effect)*를 위해 사용해요. 예를 들어 이 코드 줄들은 값을 반환하지 않으니 부수 효과를 위해 쓰이는 거예요.
if a == b then action()
println("Hello")
첫 번째 예시는 a와 b가 같을 때 부수 효과로 action 메서드를 실행해요. 두 번째 예시는 문자열을 STDOUT에 출력하는 부수 효과를 위해 쓰여요. Scala를 배우다 보면 자연스럽게 문보다 표현식을 더 많이 쓰게 될 거예요.
for 루프
가장 단순한 용도에서 Scala for 루프는 컬렉션의 원소를 순회하는 데 써요. 예를 들어 정수 시퀀스가 주어졌을 때, 그 원소들을 돌며 값을 출력하려면 이렇게 해요.
val ints = Seq(1, 2, 3)
for i <- ints do println(i)
i <- ints 코드를 *제너레이터(generator)*라고 해요. 어떤 제너레이터든 p <- e에서 표현식 e는 패턴 p에 바인딩되는 값을 0개 이상 만들어 낼 수 있어요.
Scala REPL에서는 이렇게 결과가 보여요.
scala> val ints = Seq(1,2,3)
ints: Seq[Int] = List(1, 2, 3)
scala> for i <- ints do println(i)
1
2
3
for 제너레이터 뒤에 여러 줄짜리 코드 블록이 필요하면 이 구문을 사용해요.
for i <- ints
do
val x = i * 2
println(s"i = $i, x = $x")
여러 제너레이터 (Multiple generators)
for 루프는 아래 예시처럼 여러 제너레이터를 가질 수 있어요.
for
i <- 1 to 2
j <- 'a' to 'b'
k <- 1 to 10 by 5
do
println(s"i = $i, j = $j, k = $k")
이 표현식은 이 출력을 만들어 내요.
i = 1, j = a, k = 1
i = 1, j = a, k = 6
i = 1, j = b, k = 1
i = 1, j = b, k = 6
i = 2, j = a, k = 1
i = 2, j = a, k = 6
i = 2, j = b, k = 1
i = 2, j = b, k = 6
가드 (Guards)
for 루프에는 *가드(guard)*라고 부르는 if 문도 포함할 수 있어요.
for
i <- 1 to 5
if i % 2 == 0
do
println(i)
이 루프의 출력은 다음과 같아요.
2
4
for 루프는 필요하다면 가드를 여러 개 가질 수 있어요. 이 예시는 숫자 4를 출력하는 방법 중 하나를 보여줘요.
for
i <- 1 to 10
if i > 3
if i < 6
if i % 2 == 0
do
println(i)
for와 함께 Map 사용하기 (Using for with Maps)
Map과 함께 for 루프를 사용할 수도 있어요. 예를 들어 주(state) 약어와 전체 이름을 담은 이 Map이 있다고 해볼게요.
val states = Map(
"AK" -> "Alaska",
"AL" -> "Alabama",
"AZ" -> "Arizona"
)
그러면 for를 사용해서 키와 값을 이렇게 출력할 수 있어요.
for (abbrev, fullName) <- states do println(s"$abbrev: $fullName")
REPL에서는 이렇게 보여요.
scala> for (abbrev, fullName) <- states do println(s"$abbrev: $fullName")
AK: Alaska
AL: Alabama
AZ: Arizona
for 루프가 map을 순회하면서 각 키/값 쌍은 튜플 안에 있는 변수 abbrev와 fullName에 바인딩돼요.
(abbrev, fullName) <- states
루프가 돌면서 변수 abbrev는 map의 현재 키에, 변수 fullName은 현재 map 값에 할당돼요.
for 표현식 (for expressions)
앞선 for 루프 예시들은 모두 부수 효과를 위해 쓰였어요. 특히 println으로 값을 STDOUT에 출력하기 위해서였죠.
중요한 점은 값을 반환하는 for 표현식도 만들 수 있다는 거예요. for 표현식은 yield 키워드와 반환할 표현식을 추가해서 만들어요.
val list =
for i <- 10 to 12
yield i * 2
// list: IndexedSeq[Int] = Vector(20, 22, 24)
for 표현식이 실행된 후, 변수 list는 보이는 값들을 담은 Vector가 돼요. 이 표현식이 어떻게 동작하는지는 이래요.
for표현식은 범위(10, 11, 12)의 값들을 순회하기 시작해요. 먼저 값10을 처리해서2를 곱하고, 그 결과인 값20을 yield해요.- 다음으로 범위의 두 번째 값인
11을 처리해요.2를 곱한 뒤 값22를 yield해요. 이렇게 yield된 값들이 임시 보관 장소에 쌓인다고 생각하면 돼요. - 마지막으로 루프가 범위에서 숫자
12를 가져와2를 곱해 숫자24를 만들고 yield해요. 이 시점에서 루프는 완료되어 최종 결과인Vector(20, 22, 24)를 만들어 내요.
이번 절의 의도는 for 표현식을 보여주는 것이지만, 보이는 for 표현식이 이 map 메서드 호출과 동등하다는 걸 알아두면 도움이 돼요.
val list = (10 to 12).map(i => i * 2)
for 표현식은 컬렉션의 모든 원소를 순회하면서, 그 원소들에 어떤 알고리즘을 적용해 새 리스트를 만들어야 할 때 언제든 쓸 수 있어요.
yield 뒤에 코드 블록을 사용하는 방법을 보여주는 예시를 볼게요.
val names = List("_olivia", "_walter", "_peter")
val capNames = for name <- names yield
val nameWithoutUnderscore = name.drop(1)
val capName = nameWithoutUnderscore.capitalize
capName
// capNames: List[String] = List(Olivia, Walter, Peter)
for 표현식을 메서드 본문으로 사용하기 (Using a for expression as the body of a method)
for 표현식은 결과를 만들어 내기 때문에, 유용한 값을 반환하는 메서드의 본문으로 쓸 수 있어요. 이 메서드는 주어진 정수 리스트에서 3과 10 사이에 있는 모든 값을 반환해요.
def between3and10(xs: List[Int]): List[Int] =
for
x <- xs
if x >= 3
if x <= 10
yield x
between3and10(List(1, 3, 7, 11)) // : List[Int] = List(3, 7)
while 루프
Scala while 루프 구문은 이렇게 생겼어요.
var i = 0
while i < 3 do
println(i)
i += 1
match 표현식 (match expressions)
패턴 매칭은 함수형 프로그래밍 언어의 주요 기능이고, Scala의 match 표현식은 매우 많은 능력을 갖추고 있어요.
가장 단순한 경우에는 Java의 switch 문처럼 정수 값에 기반해 case를 매칭하는 데 match 표현식을 쓸 수 있어요. 이것이 정말로 표현식이라서 결과로 평가된다는 점을 주목하세요.
// `i` is an integer
val day = i match
case 0 => "Sunday"
case 1 => "Monday"
case 2 => "Tuesday"
case 3 => "Wednesday"
case 4 => "Thursday"
case 5 => "Friday"
case 6 => "Saturday"
case _ => "invalid day" // the default, catch-all
이 예시에서 변수 i는 보이는 case들과 대조돼요. i가 0과 6 사이면 day는 그 요일을 나타내는 문자열에 바인딩돼요. 그렇지 않으면 _ 문자로 표시된 catch-all case에 매칭되어 day는 "invalid day" 문자열에 바인딩돼요.
case는 쓰여진 순서대로 고려되고 첫 번째로 매칭되는 case가 사용되므로, 어떤 값이든 매칭하는 기본(default) case는 반드시 마지막에 와야 해요. catch-all 뒤에 오는 case들은 도달할 수 없는 case(unreachable)로 경고가 나요.
이렇게 단순한 match 표현식을 작성할 때는 변수 i에 @switch 애너테이션을 쓰는 것을 권장해요. 이 애너테이션은 switch가 성능에 더 좋은 tableswitch나 lookupswitch로 컴파일될 수 없을 때 컴파일 타임 경고를 제공해요.
기본값 사용하기 (Using the default value)
match 표현식에서 catch-all 기본값에 접근해야 할 때는, case 문의 왼쪽에 _ 대신 변수 이름을 쓰고 그 변수 이름을 문의 오른쪽에서 필요한 대로 사용하면 돼요.
i match
case 0 => println("1")
case 1 => println("2")
case what => println(s"You gave me: $what")
패턴에 쓰이는 이름은 소문자로 시작해야 해요. 대문자로 시작하는 이름은 변수를 도입하지 않고, 스코프 안의 값을 매칭해요.
val N = 42
i match
case 0 => println("1")
case 1 => println("2")
case N => println("42")
case n => println(s"You gave me: $n" )
i가 42와 같다면 case N이 매칭되어 "42" 문자열을 출력해요. 그러면 기본 case에는 도달하지 않아요.
한 줄에서 여러 매칭 처리하기 (Handling multiple possible matches on one line)
앞서 말했듯 match 표현식은 많은 능력을 갖추고 있어요. 이 예시는 각 case 문에서 여러 개의 가능한 패턴 매칭을 사용하는 방법을 보여줘요.
val evenOrOdd = i match
case 1 | 3 | 5 | 7 | 9 => println("odd")
case 2 | 4 | 6 | 8 | 10 => println("even")
case _ => println("some other number")
case 절에서 if 가드 사용하기 (Using if guards in case clauses)
match 표현식의 case에서도 가드를 사용할 수 있어요. 이 예시에서 두 번째와 세 번째 case는 모두 가드를 사용해 여러 정수 값을 매칭해요.
i match
case 1 => println("one, a lonely number")
case x if x == 2 || x == 3 => println("two's company, three's a crowd")
case x if x > 3 => println("4+, that's a party")
case _ => println("i'm guessing your number is zero or less")
주어진 값을 숫자 범위에 매칭시키는 방법을 보여주는 또 다른 예시를 볼게요.
i match
case a if 0 to 9 contains a => println(s"0-9 range: $a")
case b if 10 to 19 contains b => println(s"10-19 range: $b")
case c if 20 to 29 contains c => println(s"20-29 range: $c")
case _ => println("Hmmm...")
케이스 클래스와 match 표현식 (Case classes and match expressions)
case 클래스—그리고 apply/unapply 메서드를 제대로 작성한 클래스—에서 필드를 추출해서 가드 조건에 사용할 수도 있어요. 단순한 Person case 클래스를 사용한 예시를 볼게요.
case class Person(name: String)
def speak(p: Person) = p match
case Person(name) if name == "Fred" => println(s"$name says, Yubba dubba doo")
case Person(name) if name == "Bam Bam" => println(s"$name says, Bam bam!")
case _ => println("Watch the Flintstones!")
speak(Person("Fred")) // "Fred says, Yubba dubba doo"
speak(Person("Bam Bam")) // "Bam Bam says, Bam bam!"
매칭된 패턴을 변수에 바인딩하기 (Binding matched patterns to variables)
매칭된 패턴을 변수에 바인딩해서 타입별 동작을 사용할 수 있어요.
trait Animal:
val name: String
case class Cat(name: String) extends Animal:
def meow: String = "Meow"
case class Dog(name: String) extends Animal:
def bark: String = "Bark"
def speak(animal: Animal) = animal match
case c @ Cat(name) if name == "Felix" => println(s"$name says, ${c.meow}!")
case d @ Dog(name) if name == "Rex" => println(s"$name says, ${d.bark}!")
case _ => println("I don't know you!")
speak(Cat("Felix")) // "Felix says, Meow!"
speak(Dog("Rex")) // "Rex says, Bark!"
match 표현식을 메서드 본문으로 사용하기 (Using a match expression as the body of a method)
match 표현식은 값을 반환하기 때문에 메서드의 본문으로 쓰일 수 있어요. 이 메서드는 Matchable 값을 입력 파라미터로 받고, match 표현식의 결과에 따라 Boolean을 반환해요.
def isTruthy(a: Matchable) = a match
case 0 | "" | false => false
case _ => true
입력 파라미터 a는 Matchable 타입으로 정의돼요—이 타입은 패턴 매칭을 수행할 수 있는 모든 Scala 타입의 뿌리(root)예요. 이 메서드는 입력을 매칭하고 두 개의 case를 제공해 구현돼요. 첫 번째 case는 주어진 값이 정수 0, 빈 문자열, 또는 false인지 확인하고, 이 경우 false를 반환해요. 기본 case에서는 다른 어떤 값에 대해서도 true를 반환해요. 이 메서드가 어떻게 동작하는지 보여주는 예시들이에요.
isTruthy(0) // false
isTruthy(false) // false
isTruthy("") // false
isTruthy(1) // true
isTruthy(" ") // true
isTruthy(2F) // true
match 표현식을 메서드 본문으로 사용하는 것은 매우 흔한 패턴이에요.
match 표현식은 여러 종류의 패턴을 지원해요 (Match expressions support many different types of patterns)
match 표현식을 작성하는 데 사용할 수 있는 패턴의 형태는 매우 다양해요. 예를 들어 이런 것들이 있어요.
- 상수 패턴 (예:
case 3 =>) - 시퀀스 패턴 (예:
case List(els : _*) =>) - 튜플 패턴 (예:
case (x, y) =>) - 생성자 패턴 (예:
case Person(first, last) =>) - 타입 테스트 패턴 (예:
case p: Person =>)
이 모든 종류의 패턴은 다음 pattern 메서드에 나와 있어요. 이 메서드는 Matchable 타입의 입력 파라미터를 받아 String을 반환해요.
def pattern(x: Matchable): String = x match
// constant patterns
case 0 => "zero"
case true => "true"
case "hello" => "you said 'hello'"
case Nil => "an empty List"
// sequence patterns
case List(0, _, _) => "a 3-element list with 0 as the first element"
case List(1, _*) => "list, starts with 1, has any number of elements"
case Vector(1, _*) => "vector, starts w/ 1, has any number of elements"
// tuple patterns
case (a, b) => s"got $a and $b"
case (a, b, c) => s"got $a, $b, and $c"
// constructor patterns
case Person(first, "Alexander") => s"Alexander, first name = $first"
case Dog("Zeus") => "found a dog named Zeus"
// type test patterns
case s: String => s"got a string: $s"
case i: Int => s"got an int: $i"
case f: Float => s"got a float: $f"
case a: Array[Int] => s"array of int: ${a.mkString(",")}"
case as: Array[String] => s"string array: ${as.mkString(",")}"
case d: Dog => s"dog: ${d.name}"
case list: List[?] => s"got a List: $list"
case m: Map[?, ?] => m.toString
// the default wildcard pattern
case _ => "Unknown"
=> 오른쪽의 코드를 여러 줄로 쓰는 게 더 읽기 쉽다고 생각되면 그렇게 해도 돼요. 예를 하나 볼게요.
count match
case 1 =>
println("one, a lonely number")
case x if x == 2 || x == 3 =>
println("two's company, three's a crowd")
case x if x > 3 =>
println("4+, that's a party")
case _ =>
println("i'm guessing your number is zero or less")
Scala 3에서는 match 표현식을 연결(chaining)할 수도 있어요.
i match
case odd: Int if odd % 2 == 1 => "odd"
case even: Int if even % 2 == 0 => "even"
case _ => "not an integer"
match
case "even" => true
case _ => false
match 표현식은 마침표(period) 뒤에도 올 수 있어서, 연결된 메서드 호출이 반환한 결과에 매칭하는 것을 단순하게 만들어 줘요.
List(1, 2, 3)
.map(_ * 2)
.headOption
.match
case Some(value) => println(s"The head is: $value")
case None => println("The list is empty")
try/catch/finally
Java처럼 Scala에도 예외를 잡고 관리할 수 있는 try/catch/finally 구조가 있어요. 일관성을 위해 Scala는 match 표현식과 같은 구문을 사용하고, 발생할 수 있는 서로 다른 예외들에 패턴 매칭을 지원해요.
다음 예시에서 openAndReadAFile은 이름이 뜻하는 대로 동작하는 메서드예요. 파일을 열고 그 안의 텍스트를 읽어서 그 결과를 가변 변수 text에 할당하죠.
var text = ""
try
text = openAndReadAFile(filename)
catch
case fnf: FileNotFoundException => fnf.printStackTrace()
case ioe: IOException => ioe.printStackTrace()
finally
// close your resources here
println("Came to the 'finally' clause.")
openAndReadAFile 메서드가 파일을 읽기 위해 Java java.io.* 클래스를 사용하고 자기 예외를 잡지 않는다고 가정하면, 파일을 열고 읽으려는 시도는 FileNotFoundException과 IOException 둘 다를 일으킬 수 있고, 그 두 예외는 이 예시의 catch 블록에서 잡혀요.
커스텀 제어 구조 (Custom Control Structures)
Scala에서는 자신만의 제어 구조를 정의할 수 있어요. if/then/else나 while 루프 같은 내장 제어 구조와 비슷한 방식으로 사용할 수 있는 메서드를 만들 수 있죠. 이것은 주로 *이름에 의한 파라미터(by-name parameter)*를 통해 이루어져요.
자신만의 제어 구조 정의하기 (Defining your own control structures)
이름에 의한 파라미터는 타입 앞에 =>를 붙여서 지정해요. 예를 들어 body: => Unit처럼요. 일반적인 값에 의한(by-value) 파라미터와 달리, 이름에 의한 파라미터는 메서드가 호출될 때 평가되지 않아요. 대신 메서드 안에서 참조될 때마다 매번 평가돼요.
이 기능 덕분에 필요할 때 실행되는 코드 블록을 받아들일 수 있고, 이것이 제어 로직을 정의하는 데 핵심이에요.
코드 블록을 지정된 횟수만큼 실행하는 단순한 repeat 루프 예시를 볼게요.
def repeat(n: Int)(body: => Unit): Unit =
if n > 0 then
body
repeat(n - 1)(body)
// usage
repeat(3) {
println("Hello")
}
body 파라미터는 repeat 메서드 안에서 body가 언급될 때마다 평가돼요. repeat가 여러 파라미터 리스트로 정의되어 있어서, 두 번째 인자에 블록 구문 { ... }을 사용할 수 있고, 그래서 마치 언어 키워드처럼 보이게 돼요.
Scala 3.3의 새 기능: boundary와 break (New in Scala 3.3: boundary and break)
** 이 기능은 Scala 3.3에서 도입되었어요.
Scala 3.3은 scala.util 패키지에 boundary와 break를 도입해, 비지역 반환(non-local return)이나 중첩 루프·제어 구조에서 "빠져나오기(break)"를 더 명확하고 구조적으로 처리할 수 있게 했어요. 이는 옛 scala.util.control.Breaks와 제어 흐름을 위해 예외를 던지는 메서드를 만드는 방식을 대체해요.
사용하려면 boundary 블록을 정의해요. 그 블록 안에서 break를 호출하면 그 블록에서 값을 즉시 반환할 수 있어요.
정수 리스트에서 대상 원소의 첫 번째 인덱스를 찾는 메서드 예시를 볼게요.
import scala.util.boundary, boundary.break
def firstIndex(xs: List[Int], target: Int): Int =
boundary:
for (x, i) <- xs.zipWithIndex do
if x == target then break(i)
-1
val xs = List(1, 2, 3, 4, 5)
val found = firstIndex(xs, 3) // 2
val notFound = firstIndex(xs, 99) // -1
이 예시에서:
boundary가 스코프를 설정해요.for루프가 리스트를 순회해요.x == target이면break(i)가 호출돼요. 이는 즉시boundary블록을 빠져나와i를 반환해요.- 루프가 break 없이 끝나면, 루프 뒤의 코드(
-1)가 반환돼요.
이 메커니즘은 제어 흐름을 위해 예외를 사용하는 것에 대한 구조적인 대안을 제공해요. 컴파일러는 가능하면 boundary와 break를 단순 점프로 최적화해서(예: 중첩 메서드 호출 안에서 break가 일어나지 않는 경우) 예외 처리의 오버헤드를 피할 수 있어요.
더 알아보기
- 부분 함수 (Partial Functions) —
match와 비슷한case문 - Reference: match 표현식
- Reference:
boundary/break