3P by GN⁺ | ★ favorite | 댓글 1개
  • 함수 내부의 조건 분기는 호출자 쪽으로 올리고, 반복 대상 작업은 개별 호출보다 배치 연산으로 내리면 코드 흐름과 성능을 함께 단순화할 수 있음
  • if를 위로 올리면 전제조건을 타입이나 assert로 보장하기 쉬워지고, 함수 안의 중복 검사와 불필요한 분기가 줄어듦
  • 분기 로직이 여러 함수에 흩어지면 죽은 조건이나 반복된 판단을 찾기 어려우며, enum을 만들고 다시 match하는 구조도 같은 분기를 데이터 구조로 되풀이하는 형태가 될 수 있음
  • for를 아래로 내리면 여러 객체를 한 번에 처리해 시작 비용을 상각하고, 처리 순서 재배치나 벡터화 같은 최적화 여지가 생김
  • 두 규칙을 함께 쓰면 반복문 내부 조건 재평가와 핫 루프의 분기가 줄어들어, 제어 평면의 의사결정 비용을 데이터 평면의 배치 처리로 넘기는 구조에 가까워짐

if는 호출자 쪽으로 올리기

  • 함수 내부에 if 조건이 있다면 그 조건을 호출자로 옮길 수 있는지 먼저 검토할 수 있음
    • Option<Walrus>를 받아 None이면 반환하는 함수보다, 이미 Walrus를 받는 함수가 더 단순한 형태임
    • 함수가 전제조건을 확인하고 “아무것도 하지 않는” 대신, 호출자가 확인한 뒤 타입이나 assert로 보장할 수 있음
  • 전제조건 검사를 위로 올리는 방식은 호출 경로 전체로 퍼질 수 있으며, 결과적으로 검사 횟수를 줄이는 동기가 됨
  • 제어 흐름과 if는 코드 복잡도와 버그 가능성을 키우기 쉬움
    • 복잡한 분기 로직은 상위 함수 한곳에 모이고, 실제 작업은 직선적인 하위 함수로 위임되는 구조가 읽기 쉬움
    • 복잡한 제어 흐름이 파일 전체에 흩어지는 것보다 한 화면 안의 단일 함수에 모여 있을 때 중복 조건과 죽은 조건을 알아차리기 쉬움
  • “dissolving enum” 리팩터링은 enum 생성과 match가 같은 분기를 반복할 때 유용함
    • f()가 조건에 따라 E::Foo 또는 E::Bar를 만들고, g()가 다시 match해 foo() 또는 bar()를 호출하면 분기가 두 번 나타남
    • 조건을 위로 끌어올리면 main()에서 같은 조건으로 곧바로 foo(x) 또는 bar(y)를 호출하는 형태가 됨

for는 배치 연산 쪽으로 내리기

  • 데이터 지향 사고에서는 프로그램이 보통 여러 객체 묶음을 다루고, 핫 패스도 많은 엔티티를 처리하기 때문에 뜨거워지는 경우가 많음
    • 객체의 배치 개념을 도입하고, 스칼라 버전은 배치 버전의 특수 사례로 두는 방식이 유용함
    • for walrus in walruses { frobnicate(walrus) }보다 frobnicate_batch(walruses)가 더 나은 형태임
  • 배치 처리의 주된 이점은 성능
    • 여러 대상을 한 번에 다루면 시작 비용을 상각할 수 있음
    • 처리 순서를 유연하게 바꿀 수 있고, 특정 순서로 엔티티를 처리할 필요도 줄어듦
    • 모든 엔티티의 한 필드를 먼저 처리한 뒤 다른 필드로 넘어가는 벡터화나 struct-of-array 기법을 쓸 수 있음
  • 극단적인 성능 사례로 Vectorized Interpreters Talk가 연결됨
  • 재미있는 예로 FFT 기반 다항식 곱셈이 있음
    • 여러 점에서 다항식을 동시에 평가하는 방식이 개별 점 평가를 여러 번 하는 것보다 빠를 수 있음
  • iffor 규칙은 함께 적용할 수 있음
    • 조건이 반복문 밖에 있으면 condition을 반복해서 다시 평가하지 않음
    • 핫 루프에서 분기가 제거됨
    • 벡터화 가능성이 열림
  • 같은 패턴은 미시적 수준과 거시적 수준 모두에서 작동함
    • TigerBeetle의 구조는 데이터 평면에서 객체 배치를 동시에 처리해, 제어 평면의 의사결정 비용을 상각하는 방식임
  • for 규칙의 주된 동기는 성능이지만, 표현력에도 도움이 될 수 있음
    • jQuery는 요소 컬렉션을 대상으로 동작했고 과거에 성공적이었음
    • 추상 벡터 공간의 언어는 좌표별 방정식 묶음보다 사고 도구로 더 나을 때가 있음

댓글과 토론

Hacker News 의견들
  • 내 이상한 사고 모델은 가능한 상태와 프로그램 흐름의 트리가 있고, 조건문이 그 트리를 가지치기한다는 것임
    가능한 한 일찍 가지치기해서 더 적은 가지에서만 작업하게 만드는 게 좋음
    결국 함수는 프로그램 트리를 걷거나 실제 작업을 하는 것, 둘 중 하나에 집중했으면 함

    • 이 관점은 프로그래밍 언어 이론이나 람다 계산의 작은 단계 의미론에서 보이는 모습과 잘 맞음
      표현식은 축소 규칙에 따라 반복적으로 “다시 쓰이며” 평가됨. 예를 들어 (1 + 2) + 43 + 4로, 다시 7로 바뀜
      여기에는 다음에 어느 부분식을 평가할지 정하는 합동 규칙과, 실제로 표현식을 바꿔 프로그램 상태를 바꾸는 계산 규칙이 있음
      엄격한, 즉 비지연 언어들은 대부분 모든 부분식을 부모 표현식보다 먼저 평가하게 하지만, 조건문과 무한 루프 같은 특수 구문은 예외임
      조건문에서는 모든 부분식을 평가하도록 합동 규칙이 지시하기 전에 계산 규칙이 먼저 적용되어, 문자 그대로 표현식 트리를 가지치기함
      [1]: Benjamin C. Pierce, Types and Programming Languages 추천
    • 내 사고 모델은 지금 작성 중인 코드가 놓인 구체적인 세계에 맞추는 것임
      도메인 특성, 기존 코드베이스의 패턴, 데이터 파이프라인의 어느 단계인지, 성능 특성 등을 봐야 함
      예전에는 이런 코드 구성 규칙과 휴리스틱을 만들려 했지만, 코드를 충분히 쓰다 보면 추상화 수준이 잘못되어 오래 붙들 가치가 없다고 받아들이게 됨
      이런 논의가 가짜 함수명이나 한 글자 변수에 기대는 것도 시사적임. 외부 맥락이 없는 “코드 섬”에서는 거의 어떤 규칙도 그럴듯해질 수 있기 때문임
      gh의 유일한 호출자이고 앞으로도 그럴 것이라는 편한 가정을 해야만, 이 규칙으로 죽은 분기를 드러냈다고 말할 수 있음
      실제 코드베이스에서는 보통 gh를 애초에 합치지 않은 이유가 있음
    • 인접한 모델을 하나 던져보자면, 클래스는 명사이고 함수는 동사임
    • 그렇게 이상한 모델은 아니고, 끝까지 밀고 가면 사실상 Prolog의 실행 모델에 가까워짐
  • 더 일반적인 규칙은 if를 입력의 근원 가까이 두는 것임: https://gieseanw.wordpress.com/2024/06/24/dont-push-ifs-up-p...
    외부에서 프로그램으로 들어오는 진입점, 다른 서비스에서 가져온 데이터까지 포함해 그 지점을 찾고, 핵심 로직 특히 자원을 많이 쓰는 부분에 도달하기 전에 가능한 많은 보장을 만들도록 다듬는 게 핵심임
    가능하면 그 보장을 타입에 인코딩하는 편이 좋음

    • 이건 거의 검증하지 말고 파싱하라와 같은 얘기임: https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-va...
    • 그러면 핵심 로직을 이해할 때 어떤 가정을 해도 되는지 더 흐려지지 않나? 모든 호출 체인을 일일이 따라가며 확인하는 쪽을 선호하는 건가?
  • “함수 안에 if 조건이 있다면 호출자 쪽으로 옮길 수 있는지 고려하라”는 식의 느슨한 추측은 반례가 너무 많음
    함수가 37곳에서 호출된다면 모두 if문을 반복해야 하나?
    그 함수가 getaddrinfoEnterCriticalSection이라면 API 사용자에게 if를 밖으로 밀어내야 하나?
    이 변환은 최대 두 곳에서 호출되는 내부 함수이고, 그 판단이 함수의 관심사 바깥에 있을 때만 생각할 수 있다고 봄
    다른 방법은 함수가 if만 수행하고 두 헬퍼 함수를 호출하게 만드는 것임
    호출자가 루프 안에서 조건을 밖으로 끌어올려야 한다면, 낮은 수준의 “조건 해석 헬퍼”를 쓰면 됨. 루프 안팎이 아니라 단 한 번의 if만 필요한 호출자는 if를 숨기는 편의 함수를 쓰면 됨
    다만 이건 최적화를 위한 것이며, 최적화는 좋은 프로그램 구조와 자주 충돌함
    객체지향에서는 피호출자 안의 if 판단이 곧 어떤 메서드를 부를지 고르는 메서드 디스패치로 나타남
    메서드 디스패치를 루프 밖으로 빼는 기법도 설계의 흐름을 거스를 수 있음
    예를 들어 캔버스 객체를 래스터 이미지로 채우려고 이미지 픽셀을 순회하며 canvas.putpixel(x, y, color)를 호출하고 싶지는 않음. 이미지를 캔버스나 그 사각 영역에 블리팅하는 메서드가 있어야 함

    • 함수가 37곳에서 호출된다면, 이 경우에는 함수를 참/거짓 분기를 구현하는 두 함수로 나누고 각각 21곳과 16곳에서 호출하게 만들 수 있다는 뜻에 가까울 것임
    • 여기서 핵심 단어는 consider
      이 글은 태그드 유니언 같은 것을 쓸 때 특히 나타나는 꽤 구체적인 설계 문제를 겨냥함
    • 함수가 37곳에서 호출된다면 코드를 리팩터링해야 하지만, 그래도 답은 “상황에 따라 다르다”임
      DRY가 맞는 답처럼 느껴지지만 실제 코드 예제를 봐야 판단 가능함
      라이브러리 함수라면 위치가 특수함. 소유권 경계에 있고, 데이터가 도메인을 가로지르며, DDD식으로 말하면 경계 지어진 컨텍스트를 넘어감. 그러니 자기 영역은 스스로 지켜야 함
      EnterCriticalSection은 진입 시 강한 검증, if 조건까지 포함한 검증이 타당한 코드 경로를 시사하며 도메인 경계로 봐야 함
      반면 애플리케이션을 작성하면서 일반 애플리케이션 함수에 if문이 있다면 안전하게 밖으로 밀어낼 수 있음
      라이브러리나 중요한 코드 구간 안에서도 if를 깊은 안쪽이 아니라 가장자리로 올리는 것은 안전함
      자기 도메인을 관리하고 남의 도메인에 요구하지 말며, 그 도메인 안에서는 제어 흐름을 가장자리로 옮기라는 조언은 합리적으로 들림
      물론 관용구는 관용구일 뿐이고, 실제 세계에서는 맥락을 이해하고 합리적으로 판단할 수 있는 사람이 평가해야 함
  • “열거형 분해 리팩터링” 예시는 본질적으로 다형성
    match를 열거형에 대한 다형적 메서드 호출로 바꿀 수 있음
    목적은 경우 구분이 만들어지는 지점, 즉 최초의 if와 그에 따라 foobar를 실행하는 지점을 분리하는 것임
    경우 구분은 객체, 여기서는 열거형 값이나 클로저가 들고 다니며, 호출 지점에서 다시 반복할 필요가 없음
    즉 경우 구분이 바뀌면, 그 구분이 만들어지는 지점만 바꾸면 되고 구분별 동작을 촉발하는 지점들은 바꿀 필요가 없어짐
    다만 트레이드오프가 있음. 동작이 실행되는 지점에서 고려할 개별 경우들을 직접 보는 것이 도움이 될 수 있지만, 그 대신 개별 경우 목록에 대한 코드 수준 의존성이 추가됨

  • 코드 복잡도 스캐너는 결국 if를 아래로 밀어 넣도록 강제함. 글은 그 반대를 권함
    if를 위로 올리면 제어 흐름이 한 함수에 중앙화되는 경우가 많고, 그 함수는 복잡한 분기 로직을 갖지만 실제 작업은 직선적인 하위 루틴에 위임됨
    https://docs.sonarsource.com/sonarqube-server/latest/user-gu...

    • 해결책은 판단과 실행을 분리하는 것이고, 이 개념은 Bertrand Meyer에게서 얻었음
      if (weShouldDoThis()) { doThis(); }
      이는 함수형 코어와 명령형 셸을 보완하거나 그 일부임
      검사들을 따로 두면 테스트하기 쉬워지고, 복잡도가 신경 쓰이면 검사 안의 각 절을 함수로 빼면 됨
    • 코드 스캐너 보고서는 복음처럼 받아들이지 말고 의심해야 함
      특히 Sonar는 실제 버그가 아닌 “코드 냄새”를 보고함
      이런 “버그가 아닌” 항목을 고치다 보면 새 오류가 생길 위험이 0에서 0보다 큰 값으로 올라가고, 실제 운영 이슈를 처리할 개발자 시간을 낭비할 수 있음
    • 내 경험상 이건 자주 국소 최적해
      “국소”라는 말은 요구사항이 바뀌거나 예외 상황이 발견되어 루프 바깥에서도 분기가 필요해지기 전까지만 그렇다는 뜻임
      루프 안과 밖에 모두 분기가 생기면 추론이 더 어려워짐
      조건이 루프 안쪽에만 영향을 줄 것이라고 꽤 확신한다면 거기에 둬도 됨
      하지만 루프 바깥에서도 분기가 필요해질 요구사항을 상상하기 어렵지 않다면, 미리 그런 구조로 설계하는 편이 나을 수 있음
      코드는 더 장황해질 수 있지만 따라가기 쉽고, 나중에 스파게티가 될 가능성도 줄어듦
      이게 내가 Haskell을 그만 쓴 이유임. Haskell은 가장 간결하고 “국소 최적”인 논리를 쓰고 싶게 만들지만, 그건 논리의 의도보다 논리 자체를 표현하는 데 치우침. 사소한 요구사항 변경 때 끔찍하게 풀어헤쳐야 하는 일이 생길 수 있었음
    • 완전히 읽기 쉬운 큰 함수에 불평하는 걸 본 뒤로 코드 복잡도 스캐너를 늘 싫어했음
      로직이 한곳에 있는 편이 훨씬 읽기 쉽고, 세부 사항 때문에 큰 그림을 놓치기 시작할 때만 쪼개려고 해야 함
    • 어제 LLM 스레드에서 누가 “사람들이 코딩에 받아들이는 또 다른 신뢰할 수 없는 도구가 뭐냐”고 물었는데, 이제 답이 생김
  • 때로는 조건 로직을 피호출자 안에 두는 편을 좋아함. 호출자가 실수로 잘못된 순서로 작업하지 못하게 막아주기 때문임
    예를 들어 멱등 연산을 만들고 싶다면 먼저 그 작업이 이미 끝났는지 확인하고, 아니면 실행할 수 있음
    그 조건을 호출자 밖으로 밀어내면, 함수의 모든 호출자가 멱등성 보장을 얻기 위해 올바른 방식으로 호출하는지 각자 확인해야 하고, 그 보장을 추상화해 줄 수 없음
    데이터베이스 트랜잭션 안에서 어떤 작업을 하기 전에 일련의 검사를 실행해야 하는 경우도 있음. 이런 철학을 적용하면서 검사를 트랜잭션 경계 안에 유지하려면 어떻게 해야 하나?

    • 검사가 없는 함수를 작성한 다음, 검사만 하고 내부 함수를 호출하는 래퍼 함수를 두면 될 수도 있음
    • 사실 질문 안에 답이 있음
      조건을 호출자 밖으로 밀어내면 그 함수는 더 이상 멱등이 아니므로, 당연히 보장을 제공할 수 없음
      다만 개별 함수가 상태 관리를 구현해 멱등성을 제공해야 한다면 꽤 이상한 구조일 가능성이 크고, 단일 함수 안에서 너무 많은 로직이 일어나고 있는 것 같음
      멱등 코드는 보통 두 부류로 나뉨
      하나는 데이터 모델과 수행되는 연산 자체가 본질적으로 멱등인 경우임. 상태 없는 연산이거나, 입력 데이터가 기록해야 할 모든 상태를 담고 있는 PUT 스타일 연산임
      다른 하나는 더 복잡한 비즈니스 연산으로, 롤백을 수행하거나 부분 실패가 상태를 망치지 않게 보장하는 원자적 적용 추상화를 제공해 멱등 추상화를 만드는 경우임
      첫 번째 경우에는 작업 순서를 검사할 필요가 없음. 본질적으로 멱등이니 그냥 다시 수행하면 됨
      두 번째 경우에는 단순한 추상화를 적용할 수 없음. 원하는 작업을 기록하고, 그것이 완료되거나 실패하도록 보장한 뒤, 그 완료나 실패가 영구적으로 지속되도록 해야 함
      그런 종류의 로직은 함수 하나에 넣고 다른 연산과 합성할 만한 것이 아님
  • 이 조언들은 매우 의견이 강해서 경험칙처럼 취급하면 안 됨
    여기에는 경험칙 자체가 없다고 봄. 굳이 만든다면 아마 반대로 말할 것임
    if는 DRY 때문에 아래로 밀어야 함
    성능이 허용한다면 for는 위로 올리는 것을 고려해야 함. 그러면 filter/map/reduce와 함수 합성을 써서 어떤 객체에 어떤 동작을 적용할지 고를 수 있고, 사실상 코드를 벡터화할 수 있음

    • 이름을 뒤집었거나, 제시한 이유가 결론을 뒷받침하지 않는 것 같음
      if를 아래로 밀면 보통 벡터화를 막음
      글에서 말하는 사례들은 DRY하지 않은 경우, 특히 타입이 내부적으로 태그되어 있어서 비슷한 분기가 스택 아래의 많은 함수에 증식해야 하는 경우임
  • 이게 따를 만한 “좋은” 규칙이라는 생각에는 확신이 안 듦
    어떤 때는 그럴 수도 있지만, 너무 맥락 의존적이라 결론을 내리기 어렵다
    “c 뒤를 제외하고 i는 e 앞에 온다” 같은 규칙과 비슷하게 느껴짐. 예외가 너무 많아서 사실상 규칙이 없는 것과 같음

  • Sandi Metz의 99 Bottles of OOP에서 이와 비슷한 버전을 얻었음
    전체적으로 내 스타일은 아니지만, 많은 플래그를 여러 계층 아래로 넘겨버린 코드베이스에서 작업할 때 로직의 갈림길을 호출 스택 위로 옮기라는 포인트는 매우 설득력 있었음
    https://sandimetz.com/99bottles

    • 바로 같은 저자의 The Wrong Abstraction이 떠올랐음
      분기를 for 루프 안에 넣는 것은 “for 루프가 규칙이고, 분기가 동작이다”라고 말하는 추상화임
      하지만 새 요구사항이 그 추상화를 깨는 일이 매우 흔함
      그러면 우회해야 하고, 결과 코드는 어떤 경우에는 적용되고 어떤 경우에는 적용되지 않는 추상화를 갖게 되거나, 어디에나 적용되게 하려고 추상화에 매개변수를 잔뜩 밀어 넣어 따라가기 어려워짐
      애초에 그 추상화를 만들지 않았다면 결과 코드가 더 수정하기 쉽고 이해하기 쉬웠을 수 있음
      https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction
  • 코드 가독성을 위해서는 모든 것을 아래로 밀어 넣는 편이 좋음
    printInvoice(invoice, options)if(printerReady){ if(printerHasInk){ if(printerHasPaper){ if(invoiceFormatIsPortrait){ ... }}}}보다 훨씬 나음
    루프도 마찬가지로 printInvoices(invoices)for(invoice of invoices){ printInvoice(invoice) }보다 나음
    결국 코드 가독성은 매우 중요하지만 캡슐화가 더 중요하므로 둘을 적절히 섞어야 함

    • printInvoice 함수는 청구서를 출력해야 함
      만약 이름으로 제시된 조건 중 하나가 거짓이라서 청구서를 출력할 수 없다면 어떻게 되나?
      예외를 던지거나 센티널 값 또는 오류 타입을 반환할 수 있지만, 그 경우 무엇을 해야 하는지는 즉시 명확하지 않음
      특히 일반적인 제어 흐름에 예외 사용을 꺼리고 모나드식 오류가 흔하지 않은 Java나 C++ 같은 언어에서는 두 번째 스타일에 가깝게 구조화하는 편이 더 나을 수 있음
      단, 세로 방향 형식은 오류를 나타내는 게 아니라면 청구서 프린터가 처리해야 함
      캡슐화는 주로 장기적인 코드 가독성, 국소적인 리팩터링과 변경 가능성, 그리고 국소 객체만 신경 쓰며 전역 동작을 추론하는 능력을 위한 도구로 보임
      그래서 가독성과 캡슐화를 비교해 어느 하나가 더 중요하다고 보는 것은 범주 오류처럼 느껴짐
    • “모든 것을 아래로 밀어 넣어 가독성을 높인다”고 하면서 화살표 안티패턴을 보여주는 건 별로임
      대신 이렇게 해야 함
      if(!printerReady){ return; }
      if(!printerHasInk){ return; }
      if(!printerHasPaper){ return; }
      if(!invoiceFormatIsPortrait){ return; }
      커지는 화살표보다 훨씬 읽기 쉬움
      다만 루프를 자체 함수로 넣고 나머지 가정도 이미 처리해 두는 것은 좋음
    • 이건 PC의 프린터 드라이버일 수도 있고 프린터 내부 회로일 수도 있어서 사람마다 의견이 다를 수 있음
      종이가 없을 때 프린터 자체가 바퀴를 헛돌게 해서는 절대 안 됨. 그 검사는 함수 안에 두겠음
    • Elixir 쪽이라면 그 함수 이름을 maybe_print_invoice쯤으로 붙일 텐데, 이 편이 훨씬 마음에 듦