Computer Science/일반 CS

Evaluation strategy

_빌런 2023. 9. 3. 21:30

0. Prologue

이 글은 Evaluation strategy에 대한 영문 위키피디아를 중심으로 작성한 글이다.

순서가 조금 다를 수는 있으나, 설명과 편의를 위해 작성 시 조금 수정하였다.

또한 위키피디아만으로는 부족한 것이 있어, 다른 docs와 stackoverflow도 참고하여 작성하였다.

부족하거나 간단하게 설명하는 곳이 많아, 총 정리하는 의미로 글을 작성한다.

https://en.wikipedia.org/wiki/Evaluation_strategy

 

1. Evaluation strategy

Evaluation strategy(평가 전략)이란, 프로그래밍 언어에서 표현식을 평가하는 규칙들을 말한다.

이때 표현식은 코드를 말하고, 평가는 연산을 말한다.

즉, 코드를 실행(연산)함에 있어서 어떻게 효율적으로 실행한 것인지에 대한 전략을 말한다.

 

종종 다른 말로 Parameter-passing strategy(매개 변수 전달 전략)라고도 한다.

'매개변수 전달 전략'은 'Evaluation order(평가 순서)'와 'Binding strategy(묶음 전략)'라는 두 가지 의미를 갖는다.

Evaluation order는, 함수가 어떤 순서로 매개 변수를 호출하는지 정의하는 전략이다.

언어에 따라 AST와 연산자 우선순위가 조금씩 달라, 변수들을 호출하는 순서가 다르다.

Binding strategy는, 함수에 전달하는 각 매개 변수가, 어떤 종류를 전달하는지 정의하는 전략이다.

함수에 매개 변수를 전달할 때, 값으로 주는지 주소로 주는지에 대한 이야기이다. 

 

주의할 점은 이것은 하나의 '전략'이라는 것이다.

PEP 같은 하나의 권고 사항으로써, '이렇게 해야 한다!'라고 강요할 수 없다.

또한, '이런 방식을 써서 구현하는 전략이야!'라고 정확하게 규명할 수도 없다.

이러한 방식으로 작성하는 것이 Evaluation strategy에 따라 효율적으로 볼 수 있다는 것이다. 

 

참고로 Evaluation strategy는 Reduction strategy와는 다른 개념이다.

Evaluation strategy는 코드 작성 시, 어떻게 효율적으로 값을 전달하고 '계산'할지에 대한 전략이다. 

Reduction strategy는 리팩토링 시, 어떻게 효율적으로 '감소'할 것인지에 대한 전략이다.

두 개념을 혼동하는 사람들이 많다고 wikipedia에도 적혀있으니 특히 유의하자.

(The notion of reduction strategy is distinct, although some authors conflate the two terms and the definition of each term is not widely agreed upon.)

 

2. Evaluation strategies - Evaluation order

처음에 말했듯 Evaluation order는, 함수가 어떤 순서로 매개 변수를 호출하는지 정의하는 전략이다.

호출하는 순서와 방에 따라 Strict evaluation(엄격한 평가)과 Non-strict evaluation(관대한 평가)로 나눈다.

 

Strict evaluation

def func(n):
    print(n)
    return n

func(1) + func(2)

각 언어마다 연산자 우선순위(Order of operations)가 다르다.

이는 각 언어마다 추상 구문 트리(AST; Abstract Syntax Tree)에서 정의하는 것을 따른다.

파이썬은 왼쪽에서 오른쪽으로 연산을 수행(left-to-right evaluation)하는 것이 기본이다.

그래서 위 코드를 수행했을 때, 1 2 순서대로 실행한다. 

 

let f x =  print_string (string_of_int x); x ;;
f 1 + f 2

하지만 오른쪽에서 왼쪽으로 연산을 수행(right-to-left evaluation)하는 언어도 존재한다.

OCaml이 대표적으로, 위 코드를 수행했을 때 2 1의 결과를 반환한다.

결국 이렇게 자신의 AST에 따라서 연산을 수행하는 것도, Strict evaluation(엄격한 평가)으로 본다.

왜? 자신이 따라야 하는 규칙을 엄격하게 따라야만 실행할 수 있으니까 말이다.

 

def func():
    print("func exec")
    return 1

test_list = [func() for _ in range(3)]

'''
출력 결과
func exec
func exec
func exec
'''

호출하는 순서가 엄격하다는 의미는, 모든 인자 값을 정의해야 한다는 말과 같다.

즉 인자 중 하나라도 정의되지 않은 경우, 함수의 결과가 정의되지 않으므로 엄격하게 검사하는 것이다.

즉 모든 값을 넘겨주어야 하기 때문에 Binding strategy 관점에서 Call by value라고 한다.

함수 호출에서 계산을 다 끝내고 인자로 값을 넘기기 때문에 Applicative-order evaluation이라고도 한다.

또한, 함수를 호출하는 그 즉시 수행하므로 '조급한 평가(Eager evaluation)'라고도 한다.

다른 말로는 '탐욕스러운 평가(Greedy evaluation)'라고도 부른다.

 

위의 코드는 Eager evaluation(조급한 평가 혹은 조급한 연산)에 대한 파이썬 예제 코드이다.

파이썬에서 list: [ ]는 Eager evaluation을 띄는 객체이다.

3번 반복문을 돌면서 func()을 호출하면 print("func exec")를 바로 바로 실행한다.

그래서 출력 결과로 func exec가 3번 찍히고, test_list에는 [1, 1, 1]이 들어간다.

생각하는 순서 그대로이다. 위에서 언급했듯 연산자 순서를 따라가는 것이 곧 Strict evaluation(엄격한 평가)니까.

 

Non-strict evaluation

def func():
    print("func exec")
    return 1

test_list = (func() for _ in range(3))

'''
출력 결과 없음
'''

Non-strict evaluation(관대한 평가)는 순서가 엄격하지 않다는 말이지, 마구잡이로 실행한다는 것이 아니다.

이는 함수를 계산(평가)하기 전에, 반환을 먼저 할 수 있다는 말이다.

 

계산을 하지 않은 채로 인자를 넘겨, 필요할 때마다 함수 내부에서 그때그때 계산한다.

이런 방식 때문에 Normal-order evaluation이라고 부른다.

핵심은 '필요할 때마다 연산을 수행한다'는 것이다. 그렇기에 Binding strategy 관점에서 Call by need라고 한다.

Call by need 관점에서 바로바로 연산 결과를 반환하지 않기에 Lazy evaluation라고도 부른다.

필요할 때만 수행한다는 개념을 확장한다면, 필요하지 않으면 하지 않는다는 말과 같다.

그래서 진리 표현식(Boolean expressions)에서 논리곱(and)과 논리합(or)에서 중간까지만 하는 경우가 발생한다.

이러한 경우를 Short-circuit evaluation이라고 한다. 자세한 예시는 아래 코드를 참고하자.

 

위의 코드는 Lazy evaluation(게으른 평가 혹은 게으른 연산)에 대한 파이썬 예제 코드이다.

파이썬에서 Generator: ( )는 Lazy evaluation을 띄는 객체이다.

3번 반복문을 돌면서 func()을 호출하면, 게으르게 바로 print()하지 않는다.

왜냐하면 아직 test_list에 접근하거나 실행하지 않았기 때문이다. 

그래서 test_list에는 결론적으로 [func(), func(), func()]이 들어가게 된다.

만약 test_list에 접근해서 하나씩 출력한다면, 그제서야 func exec를 출력하고 1을 출력하는 과정을 3번 할 것이다.

파이썬에서 Generator로 저렇게 표현식을 감싸는 것을 Generator expression이라고 부른다.

 

def true():
    print(True, end=" ")
    return True

def false():
    print(False, end=" ")
    return False

true() and true() and false() and true()

'''
출력 결과
True True False
'''

위의 코드는 Short-circuit evaluation에 대한 파이썬 예제 코드이다.

True를 반환하는 true() 함수와, False를 반환하는 false() 함수를 만들었다.

그리고 true()와 false() 함수를 논리곱(and) 연산으로 수행한다.

일반적으로 생각하면 함수 연산 결과가 True and True and False and True라고 생각한다.

앞에서부터 연산을 수행하면 True, False, False가 되니 최종적으로 False가 될 거야!라는 것이다.

하지만 출력 결과를 보면 true(), true(), 그 다음 false()에서 멈춰 마지막 true()를 실행하지 않는다.

논리곱에서는 하나라도 False면 False이니, 마지막 true() 함수를 수행할 필요가 없다는 것이다.

위에서 언급했듯, 필요한 만큼만 필요할 때 수행하는 것이 곧 Non-strict evaluation(관대한 평가)니까.

 

언어 Eager operators Short-circuit operators Result type
C, Objective-C &, l &&, ||, ? int
&, |, &&, ||

opnd-dependent
?, ??
C++ (없음) &&, ||, ?
C# &, | &&, ||, ?, ??
Java &, | &&, || Boolean
Python &, | and, or Last value

실제로 파이썬 뿐만 아니라 다른 언어에서도 Eager operator와 Short-circuit operator가 존재한다.

모든 언어를 다루기에는 표가 길어져, 프로그래밍 언어하면 생각나는 C, Java, Python만 가져왔다.

자세한 표는 https://en.wikipedia.org/wiki/Short-circuit_evaluation에서 확인하자.

 

3. Evaluation strategies - Binding strategy

Evaluation strategy 대표 언어 전략을 처음 소개한 시기(연 단위)
Call by reference Fortran Ⅱ, PL/1 1958
Call by value ALGOL, C, Scheme, MATLAB 1960
Call by name ALGOL 60, Simula 1960
Call by copy-restore Fortran Ⅳ, Ada 1962
Call by need SASL, Haskell, R 1971
Call by sharing CLU, Java, Python, Ruby, Julia 1974
Call by reference parameters C++, PHP, C#, Visual Basic, .NET 1985
Call by reference to const C++, C 1985

처음에 말했듯 Binding strategy는, 함수에 전달하는 각 매개 변수가, 어떤 종류를 전달하는지 정의하는 전략이다.

어떤 언어가, 어떤 종류를 전달하는지에 따라 동작 과정이 달라진다. 

매개 변수를 전해주는 동작에 따라 코드를 실행(연산) 효율성이 바뀌게 된다.

이때 매개 변수를 사용한 피호출자(Callee)와 호출자(Caller)가 존재하는 상황이다.

즉 한 영역(scope)가 아닌 함수를 두고 존재하는 각 범위에 대한 상황임을 잊지말자.

 

Evaluation order에서 Strict(엄격한)과 Non-strict(관대한)으로 구분한 것처럼 Binding strategy도 두 개로 나눈다.

Strict Binding Strategy에는 reference, value, copy-restore, sharing 전략이 속한다.

reference에는 reference paremeters와 reference to const도 같이 속한다.

Non-strict Binding Strategy에는 name, need 전략이 속한다.

Call by 형태는 매개 변수를 '전달'하는 방식이기에 때때로 Pass by라고 부르기도 한다.

 

int add(int a, int b)
{
    return (a + b);
}

add(m, n)

용어에 대해서 아주 간단하게 짚고 넘어가보자.

함수를 호출할 때 넘겨주는 m과 n을 argument(인자)라고 부른다. Actual parameter(실제 매개 변수)라고도 부른다.

함수에 적혀있는 a와 b를 parameter(매개 변수)라고 부른다. Formal parameter(형식 매개 변수)라고도 부른다.

일반적으로 parameter라고 하면, formal parameter를 일컫는다.

 

Strict binding strategy

Call by reference

일반적으로 매개 변수에 값을 전달하는 잘 알려진 두 가지 방법 중 하나이다.

객체의 값을 전달하는 게 아니라 객체를 저장한 메모리 주소를 전달하는 방식이다.

그렇기에 call by address 혹은 pass by address라는 표현을 쓰기도 한다.

C++에서는 포인터(pointer)를 활용하여 매개 변수를 주고 받는다.

이렇게 포인터와 참조 연산를 활용한 call by reference는 특히 call by reference parameter라고도 한다.

call by value와 가장 큰 차이는, 실제 값에 영향을 미치냐 안 미치냐이다.

이렇게 주소로 값을 전달해준 경우에는 실제 값에 영향을 미치게 된다. 

전달하려는 객체의 주소를 명확하게 구해서 함수에게 전해준다. Strict(엄격한)로 구분하는 이유이다.

 

def modify_reference(lst):
    lst.append(4)  # 리스트에 항목 추가

my_list = [1, 2, 3]
modify_reference(my_list)
print(my_list)  # 출력 결과: [1, 2, 3, 4]

Python  예제 코드를 살펴보자.

파이썬은 모든 변수를 객체로 선언하고, 그 주솟값을 저장하는 형태이다.

그래서 메모리 어딘가에 [1, 2, 3]을 저장하고, my_list에는 그 주소를 저장한다. 

modify_reference() 인자로 주소를 던져주고, 그 lst에 4를 추가한다.

그리고 my_list를 출력해보면 [1, 2, 3, 4]가 되었음을 알 수 있다.

파이썬으로 작성한 이 방식은 엄밀히 말하면 call by reference가 아니다.

밑에서 call by sharing에 대해서 설명할 때 추가로 설명하겠다. 

 

#include <iostream>

void modifyReference(int &x) {
    x = x + 10;
}

int main() {
    int value = 5;
    modifyReference(value);
    std::cout << value << std::endl;  // 출력 결과: 15
    return 0;
}

C++ 예제 코드를 살펴보자.

main에서 value라는 정수형 변수에 5 값을 저장했다.

modifyReference() 함수에서 주소 연산자(&)로 매개 변수의 메모리 주소를 가져온다.

변수가 존재하는 메모리를 가져왔기 때문에, x = x + 10을 하면 호출한 변수에 영향을 끼친다.

그래서 함수 호출 후 변수를 출력하면 10을 더한 값인 15를 출력한다.

 

Call by value

일반적으로 매개 변수에 값을 전달하는 잘 알려진 두 가지 방법 중 하나이다.

인자로 실제 값을 전달해주고, 매개 변수로 값을 받아 새로운 메모리를 할당해 값을 복사하여 사용한다.

호출 받은 피호출자(Callee) 범위에서만 유효한 값이지, 호출자(Caller) 범위에서 유효하지 않다.

Call by reference와 가장 큰 차이는, 실제 값에 영향을 미치냐 안 미치냐이다.

값을 전달해서 복사하여 사용하기 때문에, 실제 값에 영향을 미치지 않는다.

전달하려는 값을 계산해서 함수에게 전해주고 이를 미리 복사하여 사용한다. Strict(엄격한)로 구분하는 이유이다.

값을 계산하여 전달하는 call by value를 strict evaluation 관점에서 eager evaluation이라고 한다.

 

def modify_value(x):
    x = x + 10

value = 5
modify_value(value)
print(value)  # 출력 결과: 5

Python 예제 코드를 살펴보자.

value에서 선언한 5 값 자체를 modify_value() 함수에 인자로 주는 것이다.

그럼 값을 넘겨받아 복사하여 사용하기 때문에, x와 value는 현재 같은 5지만 다른 변수이다.

그럼 x에 10을 더해도, 실제 value에는 영향이 없다는 말이다.

만약 피호출자(callee)에서 변경한 값을 유지하고 싶다면 return을 반환한 다음, 저장하면 된다.

여기서 의문이 생길 수 있다. 파이썬은 모든 변수를 객체로 선언하고 주소를 저장한다면서?

Call by reference에서도 이야기했지만, call by sharing에서 모든 의문을 해결할 수 있다

두 코드는 이해를 돕기 위해 작성한 코드이다. 파이썬의 동작 과정은 애초에 달라서 온전하게 설명할 수 없다.

 

#include <iostream>

void modifyValue(int x) {
    x = x + 10;
}

int main() {
    int value = 5;
    modifyValue(value);
    std::cout << value << std::endl;  // 출력 결과: 5
    return 0;
}

C++ 예제 코드를 살펴보자.

value에 5를 저장한 다음 modifyValue() 함수에서 값을 그대로 받는다.

아까처럼 &가 아니라 값 자체를 받아서 복사하여 사용하는 모습이다.

복사하는 과정은 어디있냐고? 함수 동작 과정에서 내부적으로 이루어진다.

그럼 x는 value와 같은 5라는 값을 가지고 있지만, 실제로는 서로 다른 두 변수가 됐다.

그러니 x에 10을 더해도 value에는 영향을 미치지 않는다.

마찬가지로 피호출자에서 변경한 값을 호출자에서 사용하고 싶다면 return으로 반환하면 된다.

 

Call by copy-restore

이름에서 알 수 있듯, 저장한 다음에 복구한다는 과정을 거친다.

Call by copy-restore는 call by value와 유사한 binding strategy다.

Call by value 상황을 생각해보자. 피호출자 함수에서 매개 변수의 값을 아무튼 어떤 값으로 바꾸었을 것이다.

여기서 차이점이 존재한다. Call by value는 변경한 값을 호출자 범위에서 사용하고 싶다면, 반환한 뒤 저장하면 된다.

하지만 call by copy-restore는 가장 처음에 복사한 인자값을 유지하고, 어찌됐든 처음 복사한 값을 반환해준다.

복사한 다음 복사한 결과를 반환한다고 하여, copy-in-copy-out이라고도 한다.

또한, 처음에 받은 '값'을 '반환'한다고 하여 call by result 혹은 call by value result 혹은 call by value return이라고도 부른다.

Call by value return이라는 용어는 Fortran에서 주로 사용한다. 

미리 값을 저장하여 그 값을 반환해주는 방식이기에 Strict(엄격한)로 구분하는 이유이다.

 

이러한 binding strategy는 multiprocessing 환경과 RPC(Remote Procedure Call) 환경에서 사용한다.

여러 명의 사용자를 동시에 처리할 수 있는 multipricessing 환경이 있다고 해보자.

어떤 변수 x를 여러 명의 사용자가 접근하여 각각의 연산을 수행하고 싶어한다.

한 사용자가 변경한 값이 x에 영향을 미친다면? 다른 사용자들은 원하는 결과와는 다른 결과를 얻을 것이다.

그렇기에 기존의 값을 저장한다는 것이 중요해진다.

 

def modify_copy_restore(x):
    original_value = x  # 변수의 복사본 저장
    x = x + 10
    return original_value  # 원래 값을 반환

value = 5
original_value = modify_copy_restore(value)
print(value)            # 출력 결과: 5
print(original_value)   # 출력 결과: 5

Python 예제 코드를 살펴보자.

value 값을 modify_copy_restore() 함수에 전달해준다.

함수에서 가장 처음에 할 일은, 복구할 수 있게 original_value를 유지하는 것이다.

아무튼 함수에서 여러 연산을 거친 다음, 결과적으로 original_value를 반환해준다.

그럼 함수에서 어떤 과정을 거치든 간에 value와 original_value는 같은 값을 유지한다.

 

#include <iostream>

int modifyCopyRestore(int x) {
    int original_value = x;
    x = x + 10;
    return original_value;
}

int main() {
    int value = 5;
    int original_value = modifyCopyRestore(value);
    std::cout << value << std::endl;           // 출력 결과: 5
    std::cout << original_value << std::endl;  // 출력 결과: 15
    return 0;
}

C++ 예제 코드를 살펴보자.

문법만 다르지 파이썬 코드와 구조 자체는 동일하다.

값을 전달해주고, 그 값을 저장해둔다. 그리고 연산을 거친 뒤 기존 값을 반환한다.

결과적으로 처음에 넘겨준 인자와 반환받은 값은 같을 수밖에 없다.

 

Call by sharing

Call by sharing은 일부 언어에서 사용하는 방식이다.

Call by sharing으로 동작하는 언어는 우선 call by reference로 받아들인다.

이때 넘겨준 인자가 mutable 객체인지, immutable 객체인지에 따라 동작이 달라진다.

만약 mutable 객체라면, call by reference 방식 그대로 동작한다.

하지만 immutable 객체라면, call by value 방식으로 변경하여 동작한다.

이해가 안 된다면, 언어가 알아서 call by reference와 call by value를 골라서 동작한다고 생각하면 편하다.

인자로 객체를 넘겨줄 때, 객체가 무엇인지 판단하고 그에 따른 행동을 취한다. Strict(엄격한)로 구분하는 이유이다.

 

이런 두 가지 중첩된 상태로서 동작하기에 call by sharing이라고 부른다.

객체의 상태에 따라 바뀌기 때문에 call by object라고도 부른다.

정확하게는 객체의 주소로 가 상태를 확인하기 때문에 call by object reference라고 할 수 있다.

어떤 객체가 들어올지 모르기에 call by object sharing이라고도 할 수도 있다.

할당하는 방식을 객체에 따라 다르게 주어주기에 call by assignment라고도 부른다.

 

이런 과정 전체를 나타내는 정확한 용어는 'Call by sharing'이 맞다.

그리고 Python, Java, Javascript, Ruby, Scheme, OCaml 같은 많은 언어가 이 방식을 사용한다.

실제로 파이썬에서는 call by assignment라고 부르며, 자바는 call by object reference라고 부른다.

어라? 자바는 call by value인데? 맞다. 자바에서는 이를 object reference보다는 value라고 부른다. 

자바 객체들의 종류는 대부분이 immutable이고, call by sharing에 의하면 이는 call by value로 동작한다.

그렇기 때문에 사실상 call by value로 보여 그렇게 부르게 되었다.

 

하지만 "자바 call by reference"라고만 검색해도 수많은 블로그들이 자바는 call by reference가 없다고 한다.

물론 제임스 고슬링이 그런 식으로 자바를 만들었을 수 있다. 

 └ http://fredosaurus.com/JavaBasics/methods/method-commentary/methcom-20-passby.html

하지만 현재, 대부분이 call by value로 동작해서 그렇게 보이는 것이지, call by reference가 없는 게 아니다.

그럼 "자바 mutable" 객체가 무엇이 있는지 검색하면 알 수 있지 않을까? 직접 검색해보자.

분명 mutable이라고 검색했지만, 허구한 날 mutable과 immutable의 차이만 설명한다.

그리고 immutable 객체만 무엇이 있고, 어떻게 만들고 하는지 주구장창 설명한다.

왜일까? 그야 다룬 사람이 없고, 남들이 그렇다니 그렇구나 하고 그냥 넘어가는 것이다.

자바에서 mutable 객체들은 ArrayList, HashMap, StringBuilder, HashSet, LinkedList 등이 있다.

다시 말하지만 현재 자바는 call by value만 있는 게 아니다. 

 

def modify_sharing(my_list):
    my_list.append(4)  # 리스트에 항목 추가

original_list = [1, 2, 3]
modify_sharing(original_list)
print(original_list)  # 출력 결과: [1, 2, 3, 4]

Python 예제 코드를 살펴보자.

Call by reference와 call by value에서 지나왔던 코드들이 이제서야 납득할 수 있다.

파이썬에서 mutable 객체는 list, dictionary, set이 있다. immutable 객체는 그외 str, int, float, tuple 등이 있다.

list는 mutable이기에 call by reference로 동작했고, int는 immutable이기에 call by value로 동작했다.

 

def modify_sharing(my_list):
    my_list = my_list + [4]  # 리스트에 항목 추가

original_list = [1, 2, 3]
modify_sharing(original_list)
print(original_list)  # 출력 결과: [1, 2, 3]


def modify_sharing(my_list):
    my_list = my_list * 2  # 리스트에 항목 추가
    print(my_list)  # 출력 결과: [1, 2, 3, 1, 2, 3]

original_list = [1, 2, 3]
modify_sharing(original_list)
print(original_list)  # 출력 결과: [1, 2, 3]

하지만 파이썬에서 mutable 객체를 사용할 때, 한 가지 조심해야 하는 게 있다.

같은 mutable 객체에서도 = 연산인지 내장 함수 연산인지에 따라 결과가 달라지기 때문이다.

내장 함수 메소드(append, extend, insert, remove 등)를 사용하면 해당 객체에 직접 수정을 한다.

하지만 list + [ ]나 *2처럼 내장 함수가 아닌 연산을 하면, 새롭게 메모리를 할당해준다.

그래서 modify_sharing() 함수 내에 새로운 lst가 생성되어, 기존 my_list와는 다른 객체가 된다.

 

#include <iostream>
#include <vector>

void modifySharing(std::vector<int> &my_vector) {
    my_vector.push_back(4);
}

int main() {
    std::vector<int> original_vector = {1, 2, 3};
    modifySharing(original_vector);
    
    for (int num : original_vector) {
        std::cout << num << " ";  // 출력 결과: 1 2 3 4
    }
    
    return 0;
}

C++ 예제 코드를 살펴보자.

Python과 달리 C++에는 명확한 Call by sharing 개념이 없다.

그래서 위의 C++ 코드는 이해를 돕기 위한 유사한 동작 구현이라는 것을 명심하자.

위의 코드에서 vector가 mutable 객체라고 가정한다.

그럼 modifySharing() 함수에서 call by reference로 받아올 것이고, 결국 1, 2, 3, 4를 출력한다.

 

public class CallBySharingExample {
    public static void modifyList(java.util.List<Integer> myList) {
        myList.add(4);  // 리스트에 항목 추가
    }

    public static void main(String[] args) {
        java.util.List<Integer> originalList = new java.util.ArrayList<>();
        originalList.add(1);
        originalList.add(2);
        originalList.add(3);

        modifyList(originalList);

        for (int num : originalList) {
            System.out.print(num + " ");  // 출력 결과: 1 2 3 4
        }
    }
}

Java 예제 코드를 살펴보자.

위에서 언급했듯, 자바의 mutable 객체 중 하나인 ArrayList를 활용한 예제이다.

만약 자바에 call by value만 있었다면, originalList의 값은 1, 2, 3으로 고정일 것이다.

하지만 call by reference로 동작하기에 originalList를 함수 이후 호출해보면 1, 2, 3, 4를 갖고 있다.

 

Call by reference to const

#include <iostream>

void displayValue(const int &x) {
    // x를 수정하려고 하면 컴파일 오류 발생
    std::cout << x << std::endl;
}

int main() {
    int value = 42;
    displayValue(value); // value를 "Call by reference to const" 방식으로 전달

    // value 변수는 여전히 42로 유지됨
    std::cout << value << std::endl;

    return 0;
}

C++에서 특수하게 const를 활용하는 경우에는 또 다른 매커니즘을 갖는다.

이러한 binding strategy를 call by reference to const라고 하며, Strict(엄격한)로 구분한다.

const 한정자와 & 연산자를 함께 사용하여 데이터를 참조한다.

이렇게 참조하면 매개 변수 수정하지 못하도록 보장하면서 값을 읽을 수만 있다.

일반적으로 데이터 보호와 성능 향상을 위해 사용한다.

 

Non-strict binding strategy

def modify(v1, v2):
    if v1 == 1:
        return v1
        
    else:
        return v1 + v2
        
result = modify(0+1, 100/0)

Call by name과 call by need는 굉장히 유사하게 동작한다.

따라서 자세하게 동작 방식을 구분하기 위해 위와 같은 코드가 있다고 가정해보자.

보통 strict 방식으로 코드가 동작한다면, ZeroDivisionError를 띄울 것이다.

하지만 non-strict 방식에서는 계산을 최대한 늦추기 때문에 동작이 달라진다.

밑에서 설명할 call by name과 call by need를 설명한 파이썬 코드는 이해를 돕기 위한 코드이다.

파이썬은 직접적인 name과 need 방식을 지원하지 않기에, 실제와는 다른 과정을 거친다.

 

Call by name

Call by value의 변형에 가까운 형태이다. 기본적으로 매개 변수로 값을 전달해준다.

이때 매개 변수를 하나의 name으로 받아들여, 연산이 필요할 때마다 연산을 수행하는 과정이다.

미리 계산한 특정 값을 이용하는 게 아닌, 필요할 때마다 연산을 수행하는 non-strict(관대한) 방법이다.

 

def modify_name(0+1, 100/0):
    if 0+1 == 1:
        return 0+1
        
    else:
        return 0+1 + 100/0
        
result = modify_name(0+1, 100/0)

modify() 함수가 만약 call by name으로 동작한다고 했을 때를 나타낸 modify_name() 함수이다. 

modify_name()의 인자로 0+1과 100/0을 던져주었다.

하지만 100/0에서 오류가 발생하지 않는다. 100/0 자체를 하나의 '이름'으로 보기 때문이다.

그리고 0+1과 100/0가 실제 연산이 필요한 위치에서만, 그때그때 연산을 수행한다.

그럼 이 코드에서 에러는 발생하지 않는다. 100/0을 수행해서 에러를 띄우려면 else로 빠져나와야 한다.

하지만 0+1 == 1의 조건문에서 빠져나와 100/0을 만나지 않기 때문이다.

또한 non-strict이기에 result 값을 호출하거나 활용하지 않는 이상, 계산을 최대한 미룬다.

즉 위의 코드에서는 두 가지 이유로 오류가 발생하지 않는다.

 

Call by name의 경우에는 값을 넘겨받아 기존 값에 영향을 주지 않는다는 장점이 있다.

또한 에러를 바로 띄우지 않고, 실제 에러가 나는 부분의 실행이 있을 때만 에러가 발생한다.

하지만 각 매개 변수 연산이 함수 내에서 수천 번, 수만 번 일어난다면 엄청나게 시간을 잡아먹는 단점이 있다.

이런 단점을 보완하여 고안된 방법이 call by need이다.

 

Call by need

이 방법도 어떻게 보면 call by value의 변형이라고도 볼 수 있다.

동시에 call by name과 아주 비슷하게 동작하지만, 더 효율적인 방법이다.

처음에는 이름으로 받아오지만, 그 다음부터 같은 이름을 만나면 이름이 아니라 실행한 값으로 판단한다.

 

def modify_need(0+1, 100/0):
    if 0+1 == 1:
        return v1
        
    else:
        return v1 + 100/0
        
result = modify_need(0+1, 100/0)

modify() 함수가 만약 call by need로 동작한다고 했을 때를 나타낸 modify_need() 함수이다. 

마찬가지로 0+1과 100/0을 매개 변수로 받고, 0+1과 100/0을 만날 때 연산을 수행한다.

하지만 첫 번째 return을 잘 보면 0+1이 아니라 v1이다.

Call by name처럼 이름으로 따져 하나하나 수행하는 것이 아닌, 같은 이름이 있다면 그 값을 가져와 사용한다.

이런 방식을 call by need라고 한다.

마찬가지로 result 값을 호출하거나 활용하지 않는 이상 위의 동작 과정을 일어나지 않는다.

필요로 할 때만 효율적으로 계산하는 call by need를 non-strict evaluation 관점에서 lazy evaluation이라고 한다.

 

4. Epilogue

처음에 이 글을 작성하게 된 이유는 any() 때문이다.

list comprehension으로 짝수인지 찾는 코드에서 any로 값을 구하는 게 아닌가.

근데 자세히 보니 대괄호가 없이 any의 소괄호만 있었다.

조사해 보니 이게 generator expression이라는 것을 알았다.

부가적으로 lazy evaluation과 short circuit evaluation으로 메모리와 시간도 효율적이라고 한다.

 

Lazy evaluation과 short circuit evaluation을 조사하다 보니 생각보다 별 내용이 없었다.

그래서 두 개를 간단히 비교하려 했고, 그러다 eager evaluation을 발견했다.

따라가니 evaluation strategy를 발견했고, 결국 위키피디아에 도달했다.

 

평가 전략이라더니 매개 변수 전달은 무슨 말이지?

왜 갑자기 call by value랑 call by reference가 나오지? 다른 건 또 왜 이렇게 많아.

이게 다 뭐지? 찾아보니 또 이런 말을 쓰는 사람도 없고 설명도 없네?

strict는 뭐고, non strict는 왜 나눠둔 거지? 왜 여기저기서 하는 말이 다 다르지?? 

 

교수님께서 하신 말씀이 떠오른다.

교수님의 동료 중 한 분이, 중구난방으로 퍼진 용어들 때문에 곤욕을 겪었다고 한다.

그래서 시중에서 파는 관련 책들을 모조리 사서, 전부 비교 분석하여, 하나의 책으로 정리해서 출판했다.

그리고 그 책은 베스트셀러가 되었다고 한다.

 

왜 그 책이 베스트셀러가 되었는지 이제는 알 수 있다.

아주 사소하게 다른 용어들이 중구난방으로 세상에 흩어져 있다.

'정보의 바다'를 왜 '망각의 바다'로 부르는지 깨달았다.

그저 얕은 여러 개의 정보를 보고 그것이 진리인 것처럼 글을 쓰는 사람들이 있다.

그렇게 개념의 본질은 수많은 참조를 거쳐 흐릿해진다.

 

내 성격은, 우선 나를 만족하고 나를 납득하게 만들어야 한다.

그렇지 않으면 그것은 내가 아직 이해를 못 했다는 거다.

아주 사소한 하나라도 있으면 그것까지 전부 알아야 한다.

어떻게 보면 짧은 이 글을 쓰는 데 3일이나 걸린 이유이다. 장장 3일 동안 이것밖에 못했다.

덕분에 많이 알아가는 주말이었다.