Go

Go에 삼항 연산자가 도입되지 않는 이유

드리프트2 2024. 6. 2. 21:25

Go 언어에 대해 종종 "왜 삼항 연산자가 없나요?"라는 질문을 볼 수 있습니다.

 

언어 개발 측의 의견과 제 생각을 정리해 보겠습니다.

FAQ

그 답변은 Go의 FAQ에 명확히 나와 있습니다.

Go에 ?: 연산자가 없는 이유는 무엇인가요?

Go에는 삼항 테스트 연산자가 없습니다. 같은 결과를 얻기 위해서는 다음을 사용할 수 있습니다.

if expr {
    n = trueVal
} else {
    n = falseVal
}

 

Go에 ?: 연산자가 없는 이유는 언어 설계자들이 연산자가 자주 사용되면서 복잡한 식을 만들어내는 것을 보았기 때문입니다.

 

if-else 형식은 길어지지만 분명히 명확합니다.

 

언어에 필요한 조건 제어 흐름 구조는 하나만 있으면 충분합니다.

중첩을 허용하다

Go와 Python 모두 if-else를 문으로 취급하며, 식(statement)으로 취급하지 않습니다.

 

식(statement)으로 취급되지 않는다는 것은 특정한 구문으로만 작성이 가능하다는 의미입니다.

 

삼항 연산자는 그 성질상 식(statement)으로 취급됩니다.

 

식(statement)으로 취급될 경우, 각 항목이나 조건에 식이 작성될 수 있어 중첩이 허용됩니다.

 

이것이 삼항 연산자 반대파가 가장 우려하는 점입니다.

 

다음의 코드를 보면 a가 어느 조건에서 무엇이 되는지를 이해하기 어렵습니다.

 

물론 네 가지 분기로 나뉘는 것은 변하지 않으므로 디버깅 시 어느 함수가 호출되는지를 추적해야 합니다.

int a = cond1 ? cond2 ? f1() : cond3 ? true : false ? f2() : f3() : f4();

Python의 경우

삼항 연산자 도입에 관한 논의는 Python 2.3(약 10여 년 전) 이전에도 있었습니다.

 

Python 언어 설계자들은 초기에는 Go 언어 개발자들과 마찬가지로 삼항 연산자 도입에 부정적이었고 명확한 if-else 구문의 사용을 권장했습니다.

 

반복적으로 필요하다면 동일한 함수를 선언하여 사용하라고 했습니다.

 

결국 Python 2.4에는 ValueT if cond else ValueF라는 삼항 연산자와 유사한 것이 도입되었습니다.

 

그 이유는 Python 특유의 내장 표현과 결합될 때 필터 처리 기술의 용이성과 성능상의 이점이 있었기 때문입니다.

 

여러 줄에 걸쳐 for-loop를 작성하는 Go 언어에서는 Python의 경우와 같은 이점이 없습니다.

 

그러나 Python에서도 중첩 사용 시 코드를 읽는 사람에게 혼란을 줄 수 있는 문제가 있습니다.

 

그래서 내장 표현 외에는 이 표기법을 사용하는 사례가 적습니다.

 

부연 설명으로 Python 3.8에서 :=를 사용한 할당식(PEP572)의 도입 또한 언어 설계자와 커뮤니티 간에 논쟁이 있었던 유명한 사건입니다.

 

(옛날부터 Python은 기호에 독자적인 의미를 부여하는 것을 싫어하는 문화가 있었습니다.)

코드 커버리지에 대해

삼항 연산자는 CPU에게 분기 자체에 불과합니다.

 

조건에 따라 "왼쪽 항을 처리할 것인지" 또는 "오른쪽 항을 처리할 것인지"를 결정합니다.

 

다음과 같은 코드에서 A()와 B() 중 하나만 호출됩니다.

a = condition ? A() : B();

 

이 경우, 코드 커버리지는 각 분기를 각각 커버하는지 추적하는 것이 올바른 측정 방법입니다.

 

그러나 많은 테스트 도구의 코드 커버리지는 행 단위로 측정되는 경우가 많고, 특히 Go 표준 테스트 도구는 행 단위로만 측정합니다.

 

여기서 삼항 연산자를 도입하면 측정 정밀도가 떨어지거나 테스트 도구의 개선이 필요합니다.

대체 방법

if-else를 그대로 사용하기

FAQ의 답변대로입니다.

 

이렇게 하면 CPU가 수행하는 처리와 불일치하지 않고, 코드의 독자가 오해할 일도 없으며, 테스트 커버리지 측정도 정확하게 할 수 있어 문제가 없습니다.

함수를 선언하기

func conv(cond bool, T, F string) string {...}
var (
    a = conv(cond1, "hi", "bye")
)

 

최근 Go의 컴파일러는 인라인 전개가 유리한 경우 인라인 전개를 구현합니다.

 

하지만 좌우 값이 함수 호출을 동반하는 경우 삼항 연산자에 비해 단점이 있습니다.

a = conv(cond1, f1(), f2())

 

위의 경우 f1()과 f2()가 모두 호출됩니다.

 

이럴 때는 다음과 같이 함수 참조를 받도록 작성하십시오.

func conv(cond bool, T, F func() string) string {...}
var (
    a = conv(cond1, f1, f2)
)

switch 구문

Go의 switch 구문은 유연한 조건 분기를 제공합니다.

 

의외로 많이 알려져 있지 않은데, switch에 전달하는 값을 생략한 경우, 다음과 같이 임의의 조건식으로 분기를 스마트하게 작성할 수 있습니다.

 

삼항 연산자를 중첩하는 것보다 더 명확한 분기를 작성할 수 있습니다.

switch {
case cond1:
    // do something
case cond2:
    // do something
case cond3:
    // do something
}

룩업 테이블

삼항 연산자를 원하는 사람들 중에는 분기식을 원하는 것보다 간결한 "룩업 테이블"을 필요로 하는 사람들도 있을 것입니다.

 

이 경우 다음과 같이 작성할 수 있습니다.

a := map[bool]string{true: "A", false: "B"}[조건식]

 

물론 조건이 bool에 한정되지 않고, enum(iota)이나 숫자, 문자열 상수도 분기 없이 처리할 수 있습니다.

빌드 태그에 의한 전환

다음과 같은 두 개의 구현을 동일한 패키지 폴더 "imsi"에 넣습니다.

// +build !temp

package imsi

const (
    A = 123
    B = 234
)
// +build temp

package imsi

const (
    A = 223
    B = 334
)

 

다음과 같이 빌드 태그를 지정하여 어느 상수 세트를 사용할지 선택할 수 있습니다.

go build .
go build -tags temp .

요약

  • Go에 삼항 연산자는 언어 개발자의 부정적인 인식 때문에 초기 단계에서 도입이 보류되었습니다.
  • 결국, 분기는 행을 나눠서 분기가 눈에 보이는 것이 디버깅하기 쉽습니다.
  • 분기를 식으로 만들면 중첩을 허용하게 되어 구문 분석의 복잡성 및 가독성 저하가 우려됩니다.
  • 또한 테스트 코드 커버리지가 측정하기 어려워져 테스트 도구의 개선이 필요합니다.
  • 커뮤니티는 찬성파 40%, 반대파 60% 정도입니다. 그러나 찬성 비율이 올라가도 그것만으로 도입될 가능성은 낮습니다.
  • Go의 강점은 컴파일 속도와 가독성이므로 그것들에 해가 되는 시점에서 도입은 어렵습니다. (컴파일 시간에 미치는 영향이 경미하더라도)
  • 실용적인 이점은 행 수를 줄이는 것뿐이라서 위의 단점에 비해 이점이 약합니다.
  • 삼항 연산자를 추가하는 제안(Go2 포함)은 여러 번 있었지만 현재까지 모두 기각되었습니다.
  • 한 번 불채택된 기능을 도입하려면 상당한 이점이 필요합니다. (다른 언어 대부분에 있는 기능이라 하더라도)
  • Python에서는 내포 표현이 늘어나 시너지 효과가 있었기 때문에 도입되었습니다. Go에서도 그런 일이 생기면 재검토될 수 있지만 현재로서는 없습니다.
  • 문자 수와 행 수는 늘어나지만, 명시적인 대체 방법이 여러 가지 있습니다.
  • 삼항 연산자는 "분기"와 "룩업 테이블"의 역할을 겸하고 있지만, Go에서는 각각 별도의 기법으로 충분히 대체할 수 있습니다.
  • 개인적으로는 "룩업 테이블"을 간편하게 쓸 수 있다면 좋겠습니다. 예를 들어 다음과 같이 작성하면 타입 생략 시 map 또는 slice로 키와 값의 타입 추론이 작동하여 리터럴을 만들 수 있을 것입니다.
var (
    a = {true: "temp", false: "imsi"}[cond]
    b = {"temp", "imsi"}[index]
)

 

아, 이것도 타입 선언만 하면 되는군요.

type M map[bool]string
type S []string

var (
    a = M{true: "temp", false: "imsi"}[cond]
    b = S{"temp", "imsi"}[index]
)

부연

삼항 연산자를 만들수 있습니다.

package main

import (
    "fmt"
)

func ʔ(cond bool, t, f string) string {
    if cond {
        return t
    }
    return f
}

func main() {
    fmt.Println(ʔ(true, "yes", "no"))
    fmt.Println(ʔ(false, "yes", "no"))
}

 

그리고 제네릭스를 사용하면...?

package main

import (
    "fmt"
)

func ʔ[T any](cond bool, t, f T) T {
    if cond {
        return t
    }
    return f
}

func main() {
    fmt.Println(ʔ(true, "yes", "no"))
    fmt.Println(ʔ(false, "yes", "no"))
}