Reddit

Arguing about arguments

인자 기능을 둘러싼 논쟁

Steve Klabnik이 Rust에 named parameter, optional/default argument, function overloading을 넣을 때 생기는 복잡성을 살펴봅니다. coding agent 시대에는 named parameter만 다시 검토할 여지가 있지만, 패턴·함수 포인터·trait·평가 순서 같은 문제가 남아 있다고 설명합니다.

AI 요약

Rust 코드에서 함수 정의의 x, y는 parameter이고 호출할 때 넘기는 5, 6은 argument입니다. Steve Klabnik은 이 구분에서 출발해 Rust가 지원하지 않는 인자 관련 기능 세 가지를 Ruby, Python, Java 사례와 비교합니다. 글의 결론은 단순한 찬반이 아닙니다. 여러 기능을 한꺼번에 넣으면 언어 규칙과 API 사용법이 복잡해지지만, named parameter만 놓고 보면 coding agent가 코드를 작성하고 읽는 환경에서 다시 검토할 이유가 생겼다는 주장입니다.

논쟁의 대상은 세 가지 기능입니다

첫째는 named parameter입니다. foo(5, 6) 대신 foo(x: 5, y: 6)처럼 호출부에 매개변수 이름을 적습니다. 이름이 있으므로 xy를 순서를 바꿔 넘기는 문법도 생각할 수 있습니다. 호출부의 의미가 분명해지는 대신 코드가 길어집니다. FastAPI의 get 호출처럼 keyword argument를 20개 넘게 나열하면 장황함이 크게 늘어납니다. 글에서는 status_code=200이 숫자 200보다 명확하다는 점을 인정하면서도, 이미 변수에 담긴 값을 그대로 전달할 때 status_code=status_code처럼 이름을 반복하게 된다고 지적합니다.

둘째는 optional argument와 default argument입니다. 호출자가 일부 인자를 생략하고 함수 정의에 적힌 기본값을 쓰는 방식입니다. Ruby의 redirect_to(options = {}, response_options = {})가 사례로 제시됩니다. Rails의 redirect_to는 URL 문자열, 모델, action과 id를 담은 옵션, 상태 코드와 flash 메시지 등 여러 형태의 호출을 허용합니다. 이런 API는 짧고 편리하지만, 함수가 실제로 어떤 인자를 받을 수 있는지 문서 없이는 파악하기 어렵다고 설명합니다. 기본값이 많으면 호출부에서 입력하지 않은 값이 무엇인지도 드러나지 않습니다.

셋째는 function overloading입니다. Java의 connect(String url, int timeout)connect(String url)처럼 같은 이름에 서로 다른 signature를 둡니다. 전달한 인자에 따라 어떤 함수가 실행될지 정해집니다. 글에서는 overloading을 function dispatch의 한 형태로 분류하면서 static과 dynamic dispatch, single과 multiple dispatch, predicate dispatch, pattern matching dispatch 등을 언급하지만, 이 글에서 모두 다루지는 않습니다. Rust 커뮤니티에서 오래된 요구로 variable arity argument list까지 함께 제안되어 왔다는 점도 덧붙입니다.

Rust의 단순함을 지키려는 이유입니다

Rust에서는 Vec::new()Vec::with_capacity(5)처럼 기능마다 다른 이름을 사용합니다. Vec::new(5)Vec::new(capacity: 5)가 더 편할 수는 있지만, 함수 이름을 두 개 쓰는 비용으로 호출 규칙을 단순하게 유지할 수 있다고 봅니다. 선택지가 많아지면 optional argument에는 default argument가 필요해지고, named parameter를 위치 인자와 함께 허용할지 정해야 합니다. 각각의 기능이 서로 얽히면서 언어가 감당해야 할 규칙이 빠르게 늘어납니다.

대신 Rust에서는 복잡한 설정을 builder로 표현할 수 있습니다. Vec::new().with_capacity(5).build()처럼 작성하면 호출부는 길어지지만 언어 자체에 새 규칙을 추가하지 않아도 됩니다. Klabnik은 builder도 named argument와 optional/default argument를 흉내 내므로 항상 좋은 해법은 아니라고 봅니다. 그래도 이미 복잡하다고 평가받는 언어라면 인자 문법까지 확장하지 않는 편이 낫다는 입장입니다.

coding agent가 named parameter에 대한 생각을 바꿨습니다

작성자는 named parameter의 가장 큰 단점으로 직접 입력해야 하는 장황함을 꼽았지만, Claude 같은 coding agent가 코드를 작성한다면 그 비용을 덜 신경 써도 된다고 말합니다. image::imageops::crop_imm(&img, 10, 20, 200, 100)보다 crop_imm(image: &img, x: 10, y: 20, width: 200, height: 100)이 사람이 읽기에 분명합니다. agent가 호출부만 읽어도 10이 x 값이라는 사실을 알 수 있고, 함수 signature를 다시 찾아보는 token과 호출을 줄일 가능성도 있다고 봅니다. 다만 실제 평가를 수행한 결과는 아니며, 글쓴이도 아직 검증하지 않았다고 명시합니다.

반대로 optional argument와 default argument에는 여전히 반대합니다. 이 기능은 호출부에서 실제로 전달된 값을 감추기 때문입니다. 사람이 직접 입력하는 코드가 줄어드는 장점은 coding agent가 대신 입력하는 환경에서 약해지지만, 무엇이 생략됐는지 알기 어려운 단점은 남습니다. builder도 같은 기준으로 봅니다. 명시적인 필드 이름을 제공하는 builder는 유용하지만, 선택값과 기본값을 과도하게 숨기는 builder는 피하려고 한다고 설명합니다.

named parameter만 추가해도 해결해야 할 문제입니다

Rust의 parameter는 엄밀히 말해 이름이 아니라 pattern입니다. fn foo((x, y): (i32, i32))처럼 하나의 parameter가 두 binding을 도입할 수 있습니다. 외부에서 부르는 이름과 함수 본문에서 쓰는 내부 이름을 나누면 해결할 수 있지만, 그 자체가 새 규칙이 됩니다.

함수가 값으로 취급될 때도 문제가 생깁니다. resize(width, height)offset(dx, dy)를 조건에 따라 fn(u32, u32) 타입 변수에 담으면 함수 포인터의 parameter 이름을 무엇으로 볼지 정해야 합니다. 현재 Rust에서는 type Callback = fn(width: u32, height: u32);처럼 이름을 적어도 문서 역할만 하며, 다른 이름을 가진 bar(x, y)를 넘길 수 있습니다. named call을 도입하면 이 코드가 계속 허용되는지, 이름이 다른 함수 포인터를 거부할지 결정해야 합니다.

trait도 마찬가지입니다. trait에 fn write(&mut self, data: &[u8])라고 적고 구현부에서 bytes라는 이름을 사용하면, 호출자는 trait 정의의 이름을 따라야 하는지 구현부 이름을 따라야 하는지 정해야 합니다. Swift처럼 내부 이름과 외부 이름을 함께 허용하는 방식도 있지만, 별도 설계가 필요합니다.

평가 순서도 까다롭습니다. Rust는 현재 호출부의 인자를 왼쪽에서 오른쪽으로 평가합니다. consume(data, data.len())은 첫 번째 인자에서 data를 move한 뒤 두 번째 인자에서 빌리려 하므로 실패합니다. 반면 consume2(data.len(), data)는 먼저 길이를 읽고 다음에 소유권을 넘기므로 동작합니다. named argument로 호출 순서를 바꿀 수 있게 하면 consume(length: data.len(), data: data)가 정의부 순서를 따르는지, 호출부에 적은 순서를 따르는지 정해야 합니다. Rust의 struct 초기화도 필드를 적은 순서에 따라 move와 borrow 결과가 달라지므로, 단순한 문법 문제가 아닙니다.

마지막으로 parameter 이름 변경은 호환성 문제를 만듭니다. API 작성자가 이름을 바꾸면 타입이 그대로여도 named argument를 쓰던 호출자가 깨집니다. 이름 변경용 alias를 허용하면 호환성은 좋아지지만 언어 규칙은 더 복잡해집니다. 글쓴이는 이 문제들을 해결 불가능하다고 말하지 않습니다. 다만 기능이 겉보기보다 훨씬 많은 설계 결정을 요구한다는 근거로 제시합니다.

작성자의 결론입니다

Klabnik은 10년 넘게 Rust에 이런 기능을 넣는 데 부정적이었지만, 최근 named parameter에는 조금 열린 입장이 됐다고 말합니다. optional/default argument와 function overloading에는 여전히 회의적입니다. named parameter가 Rust에 맞는지 확신하지도 않으며, 실제 제안이 추진된다면 언어 팀의 결정을 지켜보겠다고 합니다. 글 자체는 특정 RFC를 지지하거나 반박하는 글이 아니라, 여러 인자 기능이 얽히는 지점과 자신의 생각이 바뀐 과정을 정리한 탐색에 가깝습니다.

Reddit 반응

  • @u/da_supreme_patriarch — 인자 이름을 바꿔 호출자를 깨뜨리는 리팩터링은 완전히 괜찮습니다. 오히려 named argument라면 의도된 동작이라고 봅니다. 단순한 오타가 아니라면 인자 이름을 바꿀 이유가 있을 가능성이 높고, 타입이 그대로여도 의미가 바뀌었을 수 있습니다. 그렇다면 예전 가정을 바탕으로 함수를 호출하던 코드도 반드시 깨져야 하며, 그 코드 역시 바뀌어야 합니다.
    • @u/steveklabnik1 — 이 의견에는 공감합니다. 그래서 글에서도 이것을 나쁘다고 하지 않고, 괜찮은지 묻는 사례로 표현했습니다. 겉보기에는 단순한 기능 하나가 처음 생각한 것보다 훨씬 많은 비직관적인 경계 사례를 만든다는 점을 보여주려 했습니다.
    • @u/devraj7 — 참고로 Swift는 parameter에 내부 이름과 외부 이름을 둘 수 있게 해서 이 문제를 해결합니다. 단순히 보기 좋은 이름으로 바꾸고 싶을 때 호출자를 깨뜨리지 않아도 됩니다.
  • @u/SmartAsFart — 큰 단점 없이 추가할 수 있는 기능은 named argument뿐이라는 의견에 동의합니다. 가장 단순한 형태라면 쉼표 사이에 인자 이름을 적는 주석과 비슷하게 쓸 수도 있습니다. fn foo(3, bar: 4)처럼 작성하고, 이름이 틀리면 오류를 내면 됩니다. trait를 통해 호출할 때는 trait 정의의 인자 이름을 쓰고, 타입을 직접 호출할 때는 impl의 이름을 쓰면 됩니다.
    • @u/scook0 — 저는 반대로 봅니다. 최소한의 named argument도 비용이 많습니다. 그 정도 기능만으로는 단점을 정당화할 만큼 큰 이득을 주지 못합니다.
    • @u/MrMelon54 — named argument를 해결하는 가장 쉬운 방법은 struct를 인자로 쓰는 것입니다. 누락된 필드에 기본값을 적용하는 기능도 이미 있습니다.
  • @u/AldaronLau — crop_imm()은 named argument의 좋은 사례처럼 보이지 않습니다. pattern을 쓰면 실제 parameter를 두 개로 줄이면서 더 명확하게 만들 수 있습니다. crop_imm(image, ((X, Y), (W, H)))처럼 쓰거나, 더 명시적으로는 crop_imm(image, Rect(Pos(X, Y), Size(W, H)))처럼 newtype을 사용할 수 있습니다. x, y, w, h를 나열하는 방식보다 중첩 binding과 newtype이 더 보기 좋다고 생각합니다.
    • @u/syklemil — 같은 정수 타입의 인자를 길게 나열하는 방식은 stringly typed와 비슷한 문제로 이어집니다. 다른 언어를 써 와서 named argument를 선호하지만, 많은 사례가 {x, y}{position, length}를 typechecker가 구분하지 못하는 인터페이스의 우회책일 수도 있다고 생각합니다.
  • @u/max123246 — IDE가 오래전부터 named argument를 미리 채워줬는데, 왜 AI가 named argument에 대한 생각을 바꾼 이유인지 잘 모르겠습니다.
    • @u/gmes78 — GitHub가 code review 경험을 신경 쓴다면 inlay hint도 넣을 수 있습니다. 형편없는 도구를 다루기 위해서만 언어 기능을 추가하는 일은 좋아하지 않습니다.
  • @u/scook0 — Rust에 named argument를 고려할 이유는 big bag of optionals 문제를 해결하려는 경우뿐이라고 생각합니다. 그래서 글에서 제시한 최소 기능은 시작부터 성립하지 않습니다. 큰 옵션 묶음 사용 사례를 해결하지 않으면서, 나중에 해결하려 할 때 더 복잡하게 만들고, 단독으로는 아주 작은 이득만 제공합니다. positional argument에서 named parameter를 자동으로 만드는 방식은 Python과 Ruby에서 계속 문제를 일으켰고 Rust와도 잘 맞지 않습니다.
    • @u/steveklabnik1 — forward compatibility 문제가 사소하게 다뤄졌다는 지적은 이해합니다. 글 후반부가 너무 길어졌다고 느껴서 여러 문제가 있다는 정도만 언급하려 했습니다. 큰 문제인지 작은 문제인지 말하려던 것은 아니며, 실제 설계에서 반드시 검토해야 합니다.
    • @u/teerre — 큰 옵션 묶음 문제를 해결하는 방법이 argument용 struct를 만드는 것 말고 또 있습니까?
  • @u/anxxa — function overloading을 볼 때마다 실수로 만든 버그가 떠오릅니다. 2010년에 C#으로 Xbox 360용 하드 드라이브 탐색기를 만들 때 WriteTo(uint block, PartitionInfo p)WriteTo(ulong offset) 같은 함수를 만들었습니다. 어느 날 두 인자 버전이어야 할 호출을 한 인자 버전으로 억지로 맞춰 컴파일했고, 그 결과 partition 기준 cluster 위치가 아니라 전역 disk offset에 기록했습니다. Microsoft RSA 서명이 붙은 disk 인증 데이터를 덮어써서 정식 콘솔의 디스크를 사실상 벽돌로 만들었습니다. 그 뒤로 코드를 훨씬 명시적이고 분명하게 작성하게 됐고, 지금 Rust에서 좋아하는 점도 코드를 읽을 때 무슨 일이 일어나는지 질문이 많지 않다는 점입니다.
    • @u/hongooi — 그 사례는 overloading보다 type coercion 문제에 가까워 보입니다. 두 인자 호출을 어떻게 한 인자 함수로 강제했는지 궁금합니다.
    • @u/anxxa — function overloading에서는 완전히 다른 인자를 받는 같은 이름의 함수를 함께 정의할 수 있습니다. block과 partition 정보를 넘기려다 partition 정보를 빼먹어 offset 기반 함수가 선택됐습니다. 진짜 문제는 의미가 전혀 다른 구현을 혼동하기 쉬운 인자로 같은 이름 아래 둔 것이었습니다.
  • @u/nicoburns — named/default argument가 실제로 들어오면 postfix .await와 비슷할 수도 있다고 생각합니다. 도입 전에는 무섭고 논쟁적이었지만, 막상 들어오고 나면 사용하기 좋고 복잡성도 문제가 되지 않을 수 있습니다. Python에서 parameter가 100개인 god function을 본 경험 때문에 사람들이 겁내는 것 같지만, Rust 생태계는 그런 API를 덜 만들 것이라고 봅니다.
    • @u/steveklabnik1 — postfix .await 사례를 자주 생각합니다. 저도 당시에는 postfix await에 확신이 없었습니다. prefix 문법을 썼을 때 postfix 문법을 알려주는 custom error handling이 도입을 결정하는 데 큰 도움이 됐고, 실제로는 문제가 되지 않았습니다.
    • @u/nicoburns — named/default argument는 argument마다 optional 여부를 지정하기 쉬워서 더 나은 type safety를 제공할 수 있습니다. builder로 표현하기 어려운 부분입니다. parameter 문서를 함수에 직접 붙이고 IDE에서 쉽게 자동 완성하는 장점도 있습니다.
    • @u/steveklabnik1 — 그 장점에는 동의하지만, 저는 optional argument를 거의 원하지 않습니다. pervasive null 없이 software를 작성하면 대체로 더 낫다고 생각합니다. 다만 실제 데이터가 아닌 개인적인 선호라는 점은 인정합니다. parameter별 문서도 복잡한 상황에서는 유용한 설명보다 foo: foo 식의 문서가 되기 쉽다고 느낍니다.
  • @u/its_artemiss — optional argument와 정의 순서에 따른 평가를 함께 허용하지 않는 named argument는 별 의미가 없다고 생각합니다. named argument가 주는 명확성은 LSP inlay hint로도 얻을 수 있고, 언어와 소스 코드에 장황함을 추가하지 않아도 됩니다. 이미 원하면 쓸 수 있는 기능을 위해 비용을 크게 치르는 것처럼 보입니다.
    • @u/Longjumping_Cap_3673 — 웹 페이지에서 코드를 읽을 때처럼 annotation을 추가할 수 없는 상황도 자주 있습니다.
    • @u/scook0 — 특정 함수 호출부에 최신 /* x: */ 주석이 없으면 경고하는 opt-in Clippy lint도 만들 수 있습니다. 논쟁적인 언어 변경 없이 대부분의 이점을 얻습니다. 사람들이 지금 그런 주석을 쓰지 않는다면, 언어 지원이 가치 있다고 봐야 할 이유도 약합니다.
  • @u/redlaWw — default argument가 수십 개씩 붙은 함수가 우스운 것처럼, 설정별로 2^n개의 생성자를 만드는 현재 상황도 우습습니다. builder와 config struct가 일부 문제를 해결하지만, collection을 만드는 정도의 일에는 과합니다. 간단한 타입에는 default argument를 쓰고, 복잡한 설정에는 builder와 config struct를 쓰는 체계가 필요합니다. Rust 코드베이스에서 좋은 원칙을 지키도록 lint와 커뮤니티 관행을 함께 만들면 됩니다.
  • @u/JoshTriplett — lang team 관점에서 글의 거의 모든 내용에 동의합니다. named argument가 숨은 복잡성을 만든다는 설명은 분명합니다. 다만 named argument도 추가하지 않는 편이 좋다고 생각합니다. 앞으로 더 복잡한 기능을 얹는 발판이 될 수 있고, 다른 방식으로 해결하는 편이 낫습니다. 그래도 argument 관련 여러 제안 중에서는 named argument만 단독으로 넣는 방식이 가장 덜 해롭다는 점에는 동의합니다.
  • @u/Recatek — 이 논쟁을 다룬 글이 너무 많아 이제 지쳤습니다. 결정을 내려서 Vec::try_with_capacity 같은 API가 막히지 않게 해주면 좋겠습니다. Vec와 비슷한 타입에서 try_나 다른 prefix와 suffix가 계속 늘어나는 문제가 이미 있습니다.
  • @u/harrison_mccullough — 글을 재미있게 읽고 있습니다. 한 가지 오타가 있는 것 같습니다. Named parameters 절에서는 @app.get이 23개의 keyword argument를 받는다고 했는데, Optional and/or Default arguments 절에서는 22개라고 합니다.
    • @u/steveklabnik1 — 감사합니다. 처음에는 self를 세지 않았다가 수정했는데, 다른 부분 하나를 놓친 것 같습니다.
    • @u/harrison_mccullough — named parameter가 22개이고 이름이 없는 path argument를 더해서 23개라는 뜻으로 보입니다. 처음 읽을 때 헷갈렸습니다.
  • @u/nonotan — response_model=response_model, status_code=status_code처럼 쓰는 방식은 장황하지만 실제로 중복되지는 않는다고 생각합니다. 변수 이름을 위치로 전달하면 parameter 순서와 변수 사이에 대응 관계만 생깁니다. 실수나 함수 변경으로 대응 관계가 어긋나고 타입까지 우연히 맞으면 아무 경고 없이 컴파일됩니다. named argument를 원하는 가장 큰 이유는 가독성보다 정의부와 호출부가 어긋나는 문제를 막기 위해서입니다. 또 다른 해법으로는 newtype을 더 적극적으로 써서 함수의 각 인자에 서로 다른 타입을 부여하는 방법이 있습니다. 순서를 바꾸거나 의미가 바뀌면 컴파일 오류가 나고, 모호한 경우에는 호출부에서 newtype을 명시하게 만들 수 있습니다.

원문: Reddit / 번역·요약: Trawling