이중옵셔널에 대하여 – Swift

Swift의 캐스팅 연산자인 as? 는 T 타입으로 캐스팅에 실행할 가능성을 내포하기 때문에 T? 타입을 만들게 된다. 이 때 변환하려는 값이 이미 옵셔널타입인 경우에는 nil이 아니면 T 타입으로 간주하여 최종 결과는 T??가 아닌 T?가 된다.

var a: Any? = 8
if let x = a as? Int { 
// a는 Any?이면서 그 값이 Int 형으로 변환될 수 있으므로 
// Int 형으로 변환된 후에 옵셔널로 마크되어 Int? 타입이 된다.
// 다시 이 값은 let x = 에 의해서 언래핑되고 if 체크를 통과한다. 
  print(x)
}

// -> 8이 출력됨

위 예에서 aAny? 타입인데, as? 를 통해서 Int로 변환하려고 한다. a의 값은 8이므로 Int로 변환이 가능하니 그 명시적인 타입이  Any, Any?이든 상관없이 그 캐스팅의 결과는 그 타입이 Int? 가 되어 언래핑된 결과는 Int 타입으로 표시된다.

그런데 다음의 경우는 조금 이상하다. try? 를 통한 예외를 던질 수 있는 함수의 호출은 그 결과가 옵셔널로 변환된다. 따라서 Any?를 다시 as? Int로 적용하려 하는데 그 결과는 Int가 아닌 Int?가 된다.

enum SomeError: Error {
 case error
}

func divide(x:Int, y:Int) throws -> Any {
  guard y > 0 else { throw SomeError.error }
  return x / y
}

if let x = try? divide(x:8, y:4) as? Int {
// 예상 : try? divide(x:8, y:4) -> (2 : Any?)
// (2 : Any?) as? Int -> (2: Int?)
// 하지만 실제로는 Int?? 타입이며
// if 문 내로 들어갔을 때 x는 Optional(2)가 된다.
  print(x)
}
// -> Optional(2)

이건 사실 위 표현식에서 try?의 우선순위가 as? 보다 낮기 때문에 divide()가 실행된 결과가 Any 타입에서 Int?로 바뀌고 이것이 try? 때문에 Int?? 타입이 된 것이다. (이건 좀 이상하다. 단일 값에 대해서 try? 를 적용하게 된다는 의미가 아니던가) 이 문제는 사실 다음과 같이 괄호로 우선순위 문제를 정리해주면 해결된다.

if let x = (try? divide(x:8, y:4)) as? Int {
  print(x) // 2
}

만약 이렇게 이중으로 옵셔널이 만들어질 수 있는 경우에는 어떤식으로 언래핑해야 할까?

두 번 언래핑하기

다행히, if 절은 컴마를 통해서 여러 조건을 나열할 수 있다. (이 때 and로 평가된다.) 그래서 이중 옵셔널을 두 개의 이름을 사용해서 두 번 벗겨내면 된다.

if let x = try? divide(x:8, y:4) as? Int, let y = x
{ print(y) }

다만 이 방법에서는 중간 변수인 x는 실제로 블럭 내에서 사용하지 않는 이름이 되기 때문에 괜히 변수 이름 하나를 낭비하는 느낌도 있다.

이중 옵셔널 캐스팅 패턴 사용하기

널리 알려지지 않았지만 이렇게 이중, 삼중으로 옵셔널이 적용된 타입의 값을 한 번에 언래핑하는 방법이 있다. 사실 옵셔널 바인딩 패턴 (if let x = 옵셔널값)은 일종의 문법적인 장식이며 이는 case let x? =  을 let x = 으로 바꿔서 표현하게 해주는 것이다. 따라서 원래의 패턴을 사용하면 Int?? 타입을 이중 옵셔널 캐스팅을 사용해서 한 번에 언래핑할 수 있다. case let x?? =  패턴을 사용하는 것이다.

if case let x?? = try? divide(x:2, y:1) as? Int {
  print(x)
}

플랫맵 사용하기

(지금은 널리 알려져 있는 사실인데) 옵셔널 타입도 flatMap()을 가지고 있고, 이것은 nil 이 아닐 때 자신을 벗겨내는 것이다.

let a = try? divie(x:2, y:1) as? Int // Int??
if let x = a.flatMap{$0} {
  print(x)
}

가장 깔끔한 방법은 이중 옵셔널 타입매칭이긴 한데, 가독성을 위해서는 (try? divide(x:4, y:2)) as? Int 를 쓰는 것이 가장 현명하겠다. 사실 무엇보다 이 문법에서는 try? .. as? 를 왜 이중 옵셔널로 평가하는지가 가장 애매한 상황인 듯 하다.

참고 자료 – 이중 옵셔널을 만드는 try?

Swift 메일링에 Tim Vermeulen이 같은 이슈를 제기한 바 있고, 이에 대해 Erica Sadun이 관련한 포스팅을 쓴 적이 있다. 이 글에서도 언급하듯이 try?as? 보다는 더 낮은 우선순위를 가지고 있지만, 이 문제는 본질적으로 연산자의 우선순위와 관련된 내용은 아니라는 것이다.

예를 들어 하나의 함수가 예외를 던질 수 있으면서 동시에 옵셔널 값을 리턴할 수 있다고 해보자. 간단히 위 divide() 함수가 Any가 아닌 Int?를 리턴한다고 해보자. 이 경우에는 as? 캐스팅 없이도 이중 옵셔널이 만들어진다. 즉 try?는 함수 호출에 성공하면 그 값을 옵셔널로 감싸며, 예외가 발생하면 해당 예외를 무시하고 nil로 평가하게끔 한다.  즉 아래의 코드에서는 as? 캐스팅이 없지만 그 결과는 이중 옵셔널이 된다.

func divide(x: Int, y: Int) throws -> Int? { ... }

if let x = try? divide(x: 8, y: 4) {
  print(x) // Optional(2)
}

또 이 경우에 ‘스마트하게 옵셔널 값이 성공적으로 리턴되었다면 이중 옵셔널을 만들지 않고 우아하게 축약하면 되지?’라고 생각할 수는 있겠지만 언어 레벨에서의 이러한 축약은 리턴되는 데이터의 문맥을 임의로 제거한다는 문제를 내포할 수 있다. 결국 try?에 의한 이중 옵셔널 발생 문제는 함수 디자인의 문제이지, as와의 연산 우선순위 문제는 아닌 셈이다. 그리고 이에 대해 Erica Sadun은 다음과 같은 코멘트를 붙였다.

옵셔널을 리턴하면서 예외를 발생시키는 케이스는 “너무 나간” 경우입니다. 파일 시스템에서 읽을 수 없는 디렉토리를 액세스하는 경우 예외를 발생시키면서 파일이 존재하지 않는 경우 nil을 리턴하는 함수를 생각해볼 수는 있겠지요. 불명확한 구석에도 불구하고 두 접근법을 아예 생각할 수 없는 것은 아닙니다.

하지만 개인적으로는 이와 같은 경우에는 두 개의 실패 케이스 모두 nil로 리턴하는 함수를 만들거나, 아니면 파일이 없는 경우와 디렉토리를 읽을 수 없는 경우 모두를 각각의 다른 사항의 예외로 던지도록 하는 경우가 맞다고 보여진다. 즉 애초에 throws 함수를 디자인 한다면 그 값이 옵셔널이 되지 않게끔 하는 디자인하는 센스가 우선해야 한다고 생각된다.

(Swift) 에러 핸들링 기법

에러 핸들링은 프로그램 내에서 에러가 발생한 상황에 대해 대응하고 이를 복구하는 과정이다. Swift는 에러를 던지고, 캐치하고, 이전하며 조작할 수 있는 기능을 언어의 기본 기능으로 지원한다.

어떤 동작들은 항상 유용한 결과를 내놓거나 완전하게 실행되는 것이 보장되지 않는다. 결과가 없는 경우에 이를 표현할 수 있는 옵셔널 타입을 사용할 수도 있지만, 처리 자체가 실패하는 경우, 어떤 원인으로 실패하였는지를 알면 코드가 이에 적절히 대응할 수 있도록 할 수 있기에 그 원인을 하는 것은 유용하다.

예를 들어 디스크상의 파일로부터 데이터를 읽고 처리하는 작업을 생각해보자. 여기에는 작업이 실패할 수 있는 경우가 꽤 많다. 주어진 경로에 파일이 존재하지 않거나, 파일을 읽을 수 있는 권한이 없거나 혹은 파일이 호환되는 포맷으로 인코딩 되어 있지 않을 수도 있다. 이런 상황들을 구분하는 것은 프로그램으로 하여금 에러를 스스로 해결하거나, 자동으로 해결할 수 없는 에러에 대해서 사용자에게 보고할 수 있게끔 한다. (Swift) 에러 핸들링 기법 더보기