Skip to content

2016 08 16 cbk

baekkyu cho edited this page Aug 23, 2016 · 14 revisions

4장 예외를 이용하지 않은 오류처리

<주요내용>
* 오류를 함수적으로 제기하고 처리하는 데 필요한 기본원리들을 배운다.
* 실패상황과 예외를 보통의 값으로 표현할 수 있으며, 일반적인 오류 처리-복구 패턴을 추상화한 고차함수를 작성한다.
* 스칼라 표준라이브러리의 두형식 Option, Either를 직접 만들어본다.

4.1 예외의 장단점

  • 예외는 참조투명성(referential transparency) 을 해치고, 문제가 된다.
def failingFn(i: Int): Int = {
  val y: Int = throw new Exception("fail!")   // Exception 발생
  try {
    val x = 42 + 5
    x + y    
  }
  catch { case e: Exception => 43 }    //패턴매칭
}

// --- 예상대로 예외가 발생함
scala> failingFn(5)
java.lang.Exception: fail!
  at .failingFn(<console>:11)
  ... 33 elided
  • y가 참조에 투명하지 않다.
  • x + y부분을 아래 예제처럼 치환하면 다른 결과가 나온다.
def failingFn(i: Int): Int = {
  try {
    val x = 42 + 5
    x + ((throw new Exception("fail!")): Int)
  }
  catch { case e: Exception => 43 }    //패턴매칭
}

// --- catch 문으로 인하여, 정상적으로 값을 반환함
scala> failingFn(5)
res3: Int = 43

참조에 투명한 표현식의 의미

  • 문맥(context)에 의존하지 않으며, 지역적으로 추론할 수 있다.
  • 예) 42 + 5의 의미는 더 큰 표현식에 의존하지 않는다.

참조에 투명하지 않은 표현식의 의미

  • 문맥에 의존적이고, 좀 더 전역의 추론이 필요하다.
  • 예) throw new Exception("fail") 이라는 표현식의 의미는 문맥에 크게 의존한다.

예외의 주된문제 두가지

  • 예외는 참조투명성을 위반하고, 문맥 의존성을 도입한다.
  • 치환모형의 간단한 추론이 불가능해지고, 예외에 기초한 혼란스러운 코드가 만들어진다.
  • 예외를 오류처리에만 사용하고, 흐름의 제어에는 사용하지 말아야 한다는 속설의 근원이다.
  • 예외는 형식에 안전하지 않다.
  • failingFn 함수의 형식인 Int => Int만 보고는 이 함수가 예외를 던질 수 있다는 사실을 알 수 없다.

점검된 예외

  • java의 점검된 예외(checked exception)는 적어도 오류를 처리할 것인지 다시 발생시킬 것인지의 결정을 강제하나, 결과적으로 호출하는 쪽에 판에박힌 코드가 추가된다. 더욱 중요한 점은, 점검된 예외는 고차 함수에는 통하지 않는다는 점이다.
  • 고차함수에서는 인수가 구체적을 어떤 예외를 던질지 미리 알 수 없기 때문이다.
def map[A, B](l: List[A])(f: A => B): List[B]

4.2 예외의 가능한 대안들

  //목록의 평균(mean)을 계산하는 함수.
  def mean(xs: Seq[Double]): Double =  //Seq는 컬렉션의 공통인터페이스
    if (xs.isEmpty)
      throw new ArithmeticException("mean of empty list")
    else xs.sum / xs.length
  • 위 예제는 부분함수(partial function)의 예제이다. (일부 입력에 대해서는 정의되지 않는 함수)

대안1 (가짜 값을 return하는 방법)

  • 빈 목록에 대해서 Double.NaN 리턴
  • 상황에 따라 null을 리턴
  • 특정한 경계 값(sentinel value)을 돌려준다. (-99999)

문제점

  • 오류가 소리없이 전파될 수 있다. 호출자가 이런 오류조건의 점검을 실수로 빼먹어도 컴파일러가 경고해주지 않는다.
  • 호출하는 쪽에 호출자가 '진짜' 결과를 받았는지 점검하는 명시적 if구문들이 늘어난다.
  • 다형적 코드에는 적용할 수 없다. (return 형식에 따라서는 그 형식의 경계값을 결정하는 것이 불가능할 수도 있다.)
  • 호출자에게 특별한 방침이나 호출규약을 요구한다. (mean함수를 제대로 사용하려면 호출자가 그냥 mean을 호출해서 그 결과를 사용하는 것 이상의 작업을 수행해야 한다.)

대안2 (처리할 수 없는 값을 호출자가 지정하는 방법)

  def mean(xs: Seq[Double], onEmpty: Double): Double =
    if (xs.isEmpty) onEmpty
    else xs.sum / xs.length
  • mean은 부분함수가 아닌 완전함수(total function)가 된다.

문제점

  • 정의되지 않은 경우의 처리바익을 함수의 호출자가 알고 있어야 한다.
  • 더 큰 계산과정에서 mean을 호출할 때, mean이 정의되지 않으면 그 계산 자체를 취소하는 등의 유연함을 얻을 수 없다.

4.3 Option 자료 형식

해법

  • 함수가 항상 답을 내지는 못한다는 점을 반환형식을 통해서 명시적으로 표현한다.(오류 처리 전략을 호출자에게 미룬다)
//Option은 스칼라 표준라이브러리에 존재하나, 학습을 위해 직접 만들어 보았다.
sealed trait Option[+A] //공변성
case class Some[+A](get: A) extends Option[A]
case object None extends Option[Nothing]
  • Option을 정의할 수 있는 경우에는 Some, 정의할 수 없는 경우에는 None이 된다.
//option을 이용해 재구현
def mean(xs: Seq[Double]): Option[Double] =
  if (xs.isEmpty) None
  else Some(xs.sum / xs.length)
  • 함수의 결과가 항상 정의되지는 않는다는 사실이 return형식에 반영되어 있음.
  • 입력형식의 모든 값에 대해 정확히 하나의 출력형식값을 돌려주므로, 완전함수이다.

4.3.1 Option의 사용패턴

  • Map에서 주어진 키를 찾는 함수는 Option을 돌려준다.
  • 목록과 기타 반복 가능 자료형식에 정의된 headOption과 lastOption은 순차열이 비어있지 않은 경우 첫 요소 또는 마지막 요소를 담은 Option을 돌려준다.

Option이 편리한 이유는, 오류 처리의 공통 패턴을 고차함수들을 이용해서 추출함으로써 예외 처리 코드에 흔히 수반되는 판에 박힌 코드를 작성하지 않아도 된다는 점이다.

Option에 대한 기본적인 함수들

  • Option은 최대 하나의 원소를 담을 수 있다는 점을 제외하면 List와 비슷하다.
trait Option[+A] {
  def map[B](f: A => B): Option[B]    //Option이 None이 아니면 f를 적용한다.
  def flatMap[B](f: A => Option[B]): Option[B]    //Option이 None이 아니면 f(실패할 수 있음)를 적용한다.
  def getOrElse[B>:A](default: => B): B    // default: => B는 해당인수의 형식이 B이지만 해당인수가 함수에서 실제로 쓰일 때까지는 평가되지 않음을 뜻한다. / 비엄격성(non-strictness)개념은 다음장에서..
  def orElse[B>:A](ob: => Option[B]): Option[B]    // B>:A는 B형식 매개변수가 반드시 A의 상위형식(supertype)이어야 함을 의미한다.
  def filter(f: A => Boolean): Option[A]    // 값이 f를 만족하지 않으면 Some을 None으로 변환한다.
}

기본적인 Option 함수들의 예제

case class Employee(name: String, department: String)
def lookupByName(name: String): Option[Employee]

//Option안의 결과(가 있다면)를 변환하는데 사용할 수 있다.
val joeDepartment: Option[String] = lookupByName("Joe").map(_.department)
val department: String = 
  lookupByName("Joe").
  map(_.department).
  filter(_ != "Accounting").
  getOrElse("default")
  • 어떤 호출자가 복구가능한 오류로 처리할 수 있을만한 상황이라면 예외대신 Option을 돌려주어서 호출자에게 유연성을 부여한다.
  • 계산의 매 단계마다 None을 점검할 필요가 없다 -> 그냥 일련의 변환을 수행하고, 나중에 원하는 장소에서 None을 점검하고 처리하면 된다.
  • Option[A]는 A와는 다른 형식이므로, None일 수 있는 상황의 처리를 명시적으로 수행하지 않으면 컴파일러가 오류를 낸다.

4.3.2 예외 지향적 API의 Option 합성과 승급, 감싸기

  • Option을 사용하기 시작하면 코드기반 전체에 Option이 번지게 될 것이라는 생각을 할 수도 있다.
  • 하지만 그런 부담을 질 필요는 없다. 실제로는 보통의 함수를 Option에 작용하는 함수로 승급시킬(lift) 수 있다.
//승급함수
def lift[A, B](f: A => B): Option[A] => Option[B] = _ map f

//절대값 함수에 대한 승급처리
val abs0: Option[Double] => Option[Double] = lift(math.abs)

보험료율 견적 관련 예제

  • 온라인에서 나이와 속도위반딱지 발급수를 입력받아 보험료율을 계산해야 한다.
  • 웹기반으로 입력된 값은 기본적으로 문자열로 되어있으므로, 문자열을 파싱해야한다.
  • 문자 파싱에 관련된 부분은 예외가 발생할 수도 있다라는 가정으로 Option으로 감싸도록 프로그래밍 한다.
//문자열 파싱 - 잘못된 값이 입력되면 에러가 발생한다.
scala> "112".toInt
res0: Int = 112

//Try함수를 이용
def parseInsuranceRateQuote(
    age: String, 
    numberOfSppedingTickets: String): Option[Double] = {
  val optAge: Option[Int] = Try(age.toInt) 
  val optTickets: Option[Int] = Try(numberOfSppedingTickets.toInt)
  insuranceRateQuote(optAge, optTickets)  //보험료율계산
}

//parameter a:는 엄격하지 않은 방식(= lazy) (자세한건 다음장에서 설명됨) 
//Try(age.toInt)에서 age.toInt구문이 laza로 실행됨.
def Try[A](a: => A): Option[A] = 
  try Some(a)
  catch { case e: Exception => None }

// 두 가지 핵심요인으로 연간자동차 보혐료를 계산하는 비밀공식.
def insuranceRateQuote(age: Int, numberOfSpeedingTickets: Int): Double
  • 위 함수에는 문제가 있다. 보험료율을 계산하는 함수는 parameter로 Option[Int]가 아닌 Int를 받는다.
  • 이와같은 문제는 아래 예제처럼 해결이 가능하다.
//기존에 나왔던 map과 flatMap signature
def map[B](f: A => B): Option[B]
def flatMap[B](f: A => Option[B]): Option[B]

def map2[A,B,C](a: Option[A], b: Option[B])(f: (A, B) => C): Option[C] =
  a flatMap { aa =>
    b map { bb =>
      f(aa, bb)
    }
  }

//보험료율 계산하는 부분을 아래처럼 수정한다.
insuranceRateQuote(optAge, optTickets)  //보험료율계산 (Int, Int) 에러
map2(optAge, optTickets)(insuranceRateQuote)  //map2함수를 이용하여 Option에 대응하게 한다.
  • 위와 같은 예제는 parameter가 두개인 어떤 함수라도 아무 수정 없이 Option에 대응하게만들 수 있음을 의미한다.
  • map3, map4와 같은 형태로도 만들 수 있다.

for-함축 관련

  • 스칼라에서는 승급함수들이 흔히 쓰이기 때문에, for-함축(for-comprehension)이라고 하는 특별한 구문 구조를 제공한다.
  • 해당 구문은 자동으로 flatMap, map호출들로 전개된다.
// map2 함수
def map2[A,B,C](a: Option[A], b: Option[B])(f: (A, B) => C): Option[C] =
  a flatMap { aa =>
    b map { bb =>
      f(aa, bb)
    }
  }
// for함축으로 재구현
def map2[A,B,C](a: Option[A], b: Option[B])(f: (A, B) => C): Option[C] =
  for {
    aa <- a
    bb <- b
  } yield f(aa, bb)
  • yield키워드 다음 표현식에서는 임의의 <- 묶음 좌변에 있는 값을 사용할 수 있다.
  • 컴파일러단에서 이러한 묶음들을 flatMap 호출로 전개하고, 마지막 묶음과 yield는 map호출로 변환해준다.

4.4 Either 자료형식

  • Option은 예외적인 조건이 발생했을 때 무엇이 잘못되었는지에 대한 정보는 제공하지 못한다는 단점이 있다.
  • 실패에 관해 알고 싶은 정보가 있을 경우에는 Either자료형식을 이용한다.
// Either자료형식에 대한 signature
sealed trait Either[+E, +A]
case class Left[+E](value: E) extends Either[E, Nothing]
case class Right[+A](value: A) extends Either[Nothing, A]
  • Option과의 본질적인 차이는 두 경우 모두 값을 가진다는 것이다.

  • Right은 성공을 나타낸다 (옳은(right)쪽이라는 말장난에서 비롯)

  • Left는 실패를 나타낸다

  • Either로 풀어본 mean 예제

//실패하게되면 string값을 돌려준다.
def mean(xs: IndexedSeq[Double]): Either[String, Double] = 
  if (xs.isEmpty) 
    Left("mean of empty list!")
  else 
    Right(xs.sum / xs.length)

//필요하다면 예외를 돌려줘도 된다.
def safeDiv(x: Int, y: Int): Either[Exception, Int] = 
  try Right(x / y)
  catch { case e: Exception => Left(e) }
  • 던져진 예외를 값으로 변환한다는 공통의 패턴을 추출한 함수 Try작성
def Try[A](a: => A): Either[Exception, A] = 
  try Right(a)
  catch { case e: Exception => Left(e) }
  • Right값에 대해 작용하는 map, flatMap구현
def map[B](f: A => B): Either[E, B] = 
 this match {
   case Right(a) => Right(f(a))
   case Left(e) => Left(e)
 }
// 우변에 대한 사상에서, +E 공변 주해를 만족하기 위해서는 반드시 왼쪽 매개변수를 적절한 상위형식으로 승격시킬(promote)필요가 있다.
// 하위바운드(lower type bound), EE는 E의 supertype
def flatMap[EE >: E, B](f: A => Either[EE, B]): Either[EE, B] =
 this match {
   case Left(e) => Left(e)
   case Right(a) => f(a)
 }
  • 위와 같은 정의들이 있으면 Either를 for-함축에 사용할 수 있다.
def parseInsuranceRateQuote(
    age: String,
    numberOfSpeedingTickets: String): Either[Exception, Double] = 
  for {
    a <- Try { age.toInt }
    tickets <- Try { numberOfSpeedingTickets.toInt }
  } yield insuranceRateQuote(a, tickets)

//
  • map2 함수를 구현해보고, 유효성값을 검증하는 예제에 활용해본다.
//for-함축을 이용하여 map2함수를 구현 (Either class 내부에 선언됨)
def map2[EE >: E, B, C](b: Either[EE, B])(f: (A, B) => C): Either[EE, C] = 
  for { a <- this; b1 <- b } yield f(a,b1)

// 참고
this flatMap { a =>
  b map { b1 =>
    f(a, b1)
  }
}

// 유효값을 검증하는 예제
case class Person(name: Name, age: Age)
sealed class Name(val value: String)
sealed class Age(val value: Int)

def mkName(name: String): Either[String, Name] = 
  if (name == "" || name == null) Left("name is empty")
  else Right(new Name(name))

def mkAge(age: Int): Either[String, Age] = 
  if (age < 1) Left("age is out of range.")
  else Right(new Age(age))

def mkPerson(name: String, age: Int): Either[String, Person] = 
  mkName(name).map2(mkAge(age))(Person(_, _))


//실행예제.
scala> mkPerson("", 34)
Left(name is empty)

scala> mkPerson("cho", 0)
Left(age is out of range.)
Clone this wiki locally