BQN에서 프리미티브 오버로딩이 어떤 방식으로 쓰이는지, 그 정당화와 문제점, 확장과 동치적 오버로딩의 구분, 다른 배열 언어와의 비교, 도구 친화성과 번역 가능성까지 논합니다.
식 3↕↕6은 ↕를 두 번 쓰면서 서로 완전히 다른 일을 합니다(Windows와 Range). 이는 오버로딩—하나의 값에 여러 의미를 집어넣는 관행—의 어두운 면입니다. 새로운 배열 언어는 오버로딩을 써야 할까요? 그건 그렇고 BQN은 오버로딩을 썼어야 할까요?
쉬운 대답은, BQN이 특수 문자 수를 적게 쓰는 것이 더 낫고, 따라서 그 안에 더 많은 의미를 포장하는 것이 말이 된다는 것입니다. 모든 어색한 짝들을 쪼갠다면, 키보드에 넣으려면 수정 키가 하나 더 필요할 겁니다! ASCII만 고수하고 대부분의 프리미티브에 한 글자만 쓰는 K는 프리미티브를 더 자주, 더 다양한 방식으로 오버로딩하고, 반면 스택 기반 문법이 고정 차수 프리미티브를 강제하는 Uiua는 이름을 글리프로 번역하는 방식으로 키보드 제약을 피합니다. 게다가 유니코드에서 보기 좋은 기호의 수는 예상보다 훨씬 적어서, 예를 들어 길이에는 ≠, 셰이프에는 ≢를 쓰는 것 말고는 더 나은 아이디어가 없었습니다. 유니코드 기호에 묶이지 않은 언어라면, 의미가 맞물리지 않는 프리미티브는 분리하라고 확실히 권하겠습니다. 단항 프리미티브에 키워드를 쓰는 Q의 관례는 언급할 만하고, Klong은 다중 기호 프리미티브를 시도한 보기 좋은 노력입니다.
BQN은 APL의 오버로딩을 꽤 많이 없앱니다. 모든 수식변형자(modifier)는 왼쪽 인수가 선택적인 하나의 것(다소 느슨하게는 ˜⊸⟜)으로 설명되는 게 가장 좋습니다. .의 수많은 의미는 숫자에서의 소수점과 네임스페이스 필드 참조 두 가지로 줄였습니다. 함수/연산자 오버로딩인 /⌿\⍀는 분리했습니다. K나 Dyalog의 제안처럼 괄호를 리스트에 쓰지 않아서, 원소가 0개나 1개인 리스트와의 모호함을 피합니다.
하지만 더 쪼갤 수 있는데 쪼개지 않은 것도 있습니다. 아래 목록은 몇 가지 종류의 오버로딩을 제시합니다. 각각은 이것이 하나로 봐야 하는지 둘로 봐야 하는지, 다른 언어는 다르게 답할 수도 있는 질문입니다.
⌽: 역순 versus 회전?-: 부호 반전 versus 뺄셈?↕number versus ↕list?↑: 한 축 versus 여러 축?/boolean versus /integer?오버로딩은 여러 방식으로 APL에 깊이 자리하고 있습니다. 나는 아이버슨이 같은 “공간”에 더 많은 기능을 채워 넣는 것을 근본적으로 좋은 일로 보게 되었고, I.P. Sharp에서 발표한 논문들에서 이 흐름을 발전시켰으며, 그 극대화가 J라고 생각합니다. 나는 J가 새로운 프리미티브를 줄이고, 철자 체계가 주는 여분의 공간을 연결을 드러내는 데 썼어야 한다고 보지만, J는 대신 오버로딩으로 가득 차 있습니다. 예를 들어 +:는 단항으로는 두 배, 이항으로는 NOR를 뜻합니다(후자는 너무 생소해서 J 코드를 쓸 때 피했습니다). 동시에, 오버로딩이 잘 쓰이면 프리미티브를 더 기억하고 쓰기 쉽게 만들기도 합니다. 목록의 맨 아래, 가장 방어 가능한 형태의 오버로딩부터 시작해봅시다.
APL은 파이썬 같은 의미에서의 “하나의 자명한 방법” 언어는 아니지만, 내가 “한 가지면 충분하다”라고 묘사할 원칙을 따릅니다. 즉, APL에 어떤 데이터나 계산을 표현하는 방법이 이미 있다면, 더 짧거나 빠른 코드 같은 구체적인 이득이 없다면 다른 방법을 추가하지 않습니다. 그래서 APL에서 불리언이 일종의 정수인 것이고(이 결정은 여기에서 옹호합니다), 다양한 종류의 컬렉션이나 별도의 문자열 타입 대신 하나의 배열 데이터 타입만 있는 것입니다.
이 말은, 숫자 1 같은 것이 인덱스, 개수, 불리언 등 많은 것을 뜻할 수 있고, 복제 함수 /가 반복이나 필터링을 뜻할 수도 있다는 겁니다. 오버로딩이긴 하지만, 두 경우 모두에서 일어나는 일을 수학적으로 기술하면 같기 때문에 매우 일관된 형태입니다. 하지만 이것만이 길은 아닙니다—Java나 Haskell 같은 정적 타입 언어들은 타입 시스템이 사용자를 위해 검사를 해줄 수 있도록, 클래스를 선언해 나눠 놓는 것을 선호합니다. 극단적인 예로, 사용자 입력을 받되 특정 함수에 넘기기 전에 반드시 정화(sanitize)하거나 이스케이프해야 하는 시스템이 있습니다. APL 방식이라면 안전하지 않은 입력과 안전한 입력을 모두 문자열로 표현할 텐데, 이는 분명히 위험합니다.
그러나 모든 것을 일관된 형식으로 표현하는 장점은, 한 가지에 작동하는 메서드가 많은 것에 작동하는 경향이 있다는 점입니다. 문자열을 뒤집고 싶나요? 그냥 ⌽입니다. 불리언 부정 ¬𝕩을 더 일반적으로 1-𝕩으로 정의하면, 어떤 산술은 더 분명해집니다. 예컨대 +´¬l은 (≠l)-+´l입니다. 그리고 더 높은 수준에서도 연결을 만들 수 있습니다: a⊏b⊏c ←→ (a⊏b)⊏c 같은 규칙을 배우면, 그것은 ⊏의 모든 의미에 적용됩니다. 단, a와 b가 평탄 배열(flat array)인 경우에요. 이 점은 이러한 호환적 오버로딩과 다른 종류의 확장 간의 충돌을 부각시킵니다.
숲 속으로 더 들어가 보면, APL 계열은 정의에서 엄밀히 따라 나오지 않는 방식으로 함수들을 확장합니다. 보통은 단일 프리미티브 내부에서 일어납니다. 예를 들어 First(단항 ⊑)는 자연스럽게 배열에만 적용됩니다. 그렇지 않으면 첫 원소가 없으니까요. 하지만 원자(atom)를 주면 바꾸지 않고 그대로 돌려주어, 사실상 단위 배열로 취급합니다. 이런 암묵적 승격은 프리미티브가 배열을 기대하는 곳 어디에서나 쓰입니다: 5↑1 같은 경우, 5↑<1이라는 의미는 자명하고, 그 변환을 명시적으로 요구한다면 불편할 겁니다.
그게 다라면 언급할 가치도 거의 없었을 겁니다. 더 중요한 확장 계열은 깊이(depth)를 사용해 프리미티브가 일반적으로 여러 축에서 작동하도록 하면서도 편리한 한 축 형태를 제공하는 것입니다. 또 문자 산술이 있어 'a' + 3이 됩니다. 사실, 배열 산술 자체도 큰 확장이죠?
배열 세계 밖의 예들 가운데는 BQN의 그 어떤 것보다도 나쁘다고 느끼는 것들이 있습니다. 문자열 연결에 +를 쓰는 것. 더 이상 교환법칙이나 분배법칙이 성립하지 않습니다: 이런 언어에서 a + b를 b + a로 바꾸거나 (a+b)*c를 (a*c)+(b*c)로 바꾸는 것은 안전하지 않습니다! NumPy와 MATLAB은 불리언 배열을 인덱스로 사용해 필터링을 수행할 수 있게 합니다. 이건 a[b]의 길이는 b의 길이와 같다는 규칙—사실 어떤 길이 기반 규칙이든—을 지키지 않습니다.
때로는 확장처럼 보이는 것을 하나의 더 일반적인 프리미티브로 통합할 수 있습니다. 예를 들어 APL은 스칼라 확장을 사용해서 1 + 2‿3‿4처럼 스칼라를 리스트에 더할 수 있습니다. J와 BQN은 더 일반적인 선도 축 합의를 사용하며, 이는 이 확장을 특수 경우로 포함합니다(부수적으로, BQN은 Rank-0 인자에서 Reverse 같은 리스트류 함수들을 정의하는 덜 원칙적인 확장 일부를 제거합니다). 문자 산술도, 숫자와 문자를 “문자성(characterness)” 0 또는 1과 수치값의 쌍으로 보아 이런 식으로 볼 수 있습니다.
많은 프리미티브 쌍—-, ⋆, «, ⥊, ⍉ 등—도 이 범주에 든다고 생각합니다. 이들은 모두 일반적인 이항 함수로 서술될 수 있고, 단항 경우는 기본 왼쪽 인자(때로는 오른쪽 인자에 의존: ⍉의 경우 (=𝕩)-1)에서 나옵니다. 프리미티브 ⋈는 너무 단단히 이어져 있어서, 모든 인자를 나열한 리스트를 반환하는 완전히 호환되는 오버로드로 볼 수도 있습니다. 이런 프리미티브 쌍은 때때로 단순한 방식으로 양의성(ambivalence)을 활용할 수 있습니다(⋆⁼은 꽤 괜찮습니다). 하지만 더 자주 유용한 점은, 각 쌍을 두 개가 아니라 하나로 생각하기 더 쉽다는 것입니다. 같은 아이디어의 두 가지 관점일 뿐이죠.
좋습니다, 미끄럼을 잘 타고 내려왔으니 이제 두 절반이 정확히 같은 일을 하지 않는 프리미티브들은 어떨까요? 부드럽게 시작해 ∾를 봅시다. 이항 형태는 두 리스트를 이어 붙이고, 단항 형태는 리스트들의 리스트를 이어 붙입니다. 음, 이것은 사실 인자를 약간 특이한 방식으로 받는 하나의 함수입니다. 왜냐하면 이항 ∾는 ∾∘⋈이기 때문입니다. Prefixes/Take의 ↑(그리고 ↓도)는 비슷하지만 더 까다롭습니다: 𝕨가 0과 ≠𝕩 사이일 때 𝕨↑𝕩은 𝕩의 접두(prefix)입니다. 그때 ↑는 이런 접두들의 리스트이고, 따라서 𝕨⊑↑𝕩은 𝕨↑𝕩입니다. 일종의 부분 적용에 가깝습니다.
이런 프리미티브들은, 선택 함수 하나와 별도의 첫 셀 함수 하나를 외우는 대신 그냥 ⊏ 하나만 외우는 것이 훨씬 쉽듯이, 함께 기억하기가 더 쉽습니다. 만약 이들을 함께 오버로딩할 수 없었다면, 단항 ↑↓⊏는 아예 포함하지 않았을 것이고, √⋆, 심지어는 -도 다시 검토했을 겁니다.
/와 ⊔에서는, 단항 경우가 인덱스를 사용한다는 점(그리고 𝕨가 𝕩으로 넘어가는 사소한 문제)이 빼면 이항 경우와 비슷합니다. 어느 형태든 서로를 이용해 구현할 수 있습니다. 나는 여전히 각각의 프리미티브를 두 개의 밀접한 관계가 있는 것이 아니라 단일 개념으로 생각합니다.
그리고 나는 두 경우가 밀접하게 관련되어 있고, 프로그래밍할 때 이것을 아는 것이 유용하지만, 어느 것도 다른 하나로 쓸 수 없는—그러니까 순전히 암기용인—경우에 도달했다고 봅니다. ⍋의 두 의미는 배열의 순서를 사용하며, 그 사이의 연결은 꽤 깊습니다(제대로 파려면 글을 하나 더 써야 할지도 모릅니다). 더 약한 예는 ⌊입니다. 두 경우 모두 어떤 인자보다 작거나 같은 최댓값을 반환하지만, 단항 경우에는 결과를 정수로 제한합니다.
예제들은 이제 모두 프리미티브 쌍입니다. 이는 BQN이 프리미티브 내부의 암기용 오버로딩은 사용하지 않기 때문입니다(반면 APL은 가끔 사용합니다. 예를 들어 ○는 “실은 트렌치코트 속에 숨은 12개의 함수”이고, J는 ;.와 p: 같은 더 많은 트렌치코트 프리미티브를 추가합니다). 그리고 물론, 그 다음 단계에는 아예 연결이 없는 경우가 있습니다. BQN의 글리프는 무작위로 선택하지 않았으므로, 이런 일이 벌어지는 이유는 두 함수가 각각 그 글리프에 맞기 때문입니다. 하지만 처음에 말했듯이, 프리미티브 글리프에 묶이지 않은 언어라면 그럴 이유가 없습니다.
다음은 프리미티브들이 어떻게 맞물리는지에 관한 내 목록입니다. 표는 행으로 나뉘지만, 명확한 경계는 없습니다. 각 행의 앞쪽에 더 응집력 있는 함수 쌍을 배치하려 했습니다.
| 층위 | 프리미티브 쌍 |
|---|---|
| Unified | ⋈≍˙˘¨⌜∘○⌾⊘◶⎉⚇⎊ |
| Compatible | -÷⋆√¬⊏⊑«»⍉⥊´˝⁼⍟` |
| Similar | ∾!/⊔⊣⊢↑↓˜⊸⟜ |
| Mnemonic | ⍋⍒⊒⊐⌽∊⌊⌈ |
| Bad | `+× |
오버로딩의 실질적인 단점은 무엇일까요? 숙련된 프로그래머는 이에 익숙해지고 어려움을 겪지 않거나, 적어도 알아차리지 못할 것입니다. 더 새로운 사용자나 가끔 쓰는 사용자는 어떤 경우가 적용되는지를 쉽게 오해하고 막히기 쉽습니다. 그리고 오버로딩은 여러 종류의 도구와, 다른 언어로의 코드 변환을 방해합니다.
Uiua는 프리미티브 쌍을 분리하는 좋은 연구 대상입니다. 스택 기반 문법은 근본적으로 각 항(term)의 인자(와 결과) 수를 아는 데 의존하므로, 전부 분리해야 합니다. 그리고 Uiua는 배우기 매우 쉽고… 내려놓긴 어렵습니다: 한 사용자는 새벽 3시가 다 되어 “눈을 감기만 하면 기호가 보여요”라고 말합니다. Kai는 도구 제작에 많은 노력을 기울였지만, 동시에 한 기호가 한 가지를 뜻하는, 자동으로 설명하기 쉬운 언어를 설계했습니다. 그래서 고정 툴팁만으로도(Uiua의 웹사이트는 문자열과 주석을 처리하지만, 굳이 그래야만 하는 건 아닙니다) 코드의 한 부분 위로 마우스를 끌면, 각 프리미티브에 대한 정확한 설명을 얻을 수 있고, 걸러낼 불필요한 경우가 없습니다. BQN에서는, 가능하더라도 이를 위해 실제 문법 분석이 필요합니다. 하지만 복잡하고 자의적인 쌍만 분리해도 대부분의 이점을 얻을 수 있다는 점을 주목하세요: “역순 / 회전”의 혼란을 피하는 것은 “역수 / 나눗셈”을 함께 두느냐 마느냐와 무관하게 똑같이 유용합니다.
프리미티브 해석의 어려움은 APL식 단항/이항 오버로딩이 구문(syntax)이 아니라 값(value)에 적용된다는 사실에서 옵니다. 즉, 대부분의 언어에서 연산자는 일급 값이 아니고(일급 함수의 래퍼일 수는 있습니다) 즉시 호출되어야 합니다. 이때 피연산자 수는 알려집니다. 그래서 예컨대 뺄셈과 부호 반전을, 주변 코드만 보고도 항상 구분할 수 있습니다. K 방언들은 -를 정적으로 단항/이항 중 하나로 해석하기도 합니다. 예를 들어 ngn/k가 그렇습니다. 모호한 경우에는 기본을 이항으로 두고, 단항 의미에는 -:를 요구합니다. 그러나 양의적(ambivalent)인 -가 일급 값일 때는, Minus ← - ⋄ Minus 3 Minus 4처럼 등장 하나가 단일 의미를 갖지 않을 수도 있습니다. 이는 어떤 코드를 읽을 때 도전이 될 수 있고, 한 배열 언어에서 다른 배열 언어로 코드를 번역할 때는 큰 장벽이 됩니다.
하지만 프리미티브 쌍 이외의 종류의 오버로딩은 다른 동적 언어에도 적용됩니다. 다들 알다시피 파이썬을 루비로 그냥 번역할 수는 없죠. 만약 a[b]를 하는 NumPy 코드가 있고 이를 BQN으로 옮기려면, b가 불리언 배열인지 판단해야 b⊏a로 쓸지 b/a로 쓸지 알 수 있습니다. 경우에 따라 둘 다 필요할 수도 있습니다. 그리고 이 문제는 원자를 단위 배열로 승격하는 가장 단순한 형태의 오버로딩까지 거슬러 올라갑니다. 이런 것들을 모두 없애는 합리적인 방법이 있을까요? 아니면 충분히 강력한 정적 타입 시스템이 문제를 해결할까요? 글쎄요. 나는 아직도 “빅 앤서스” 표기법을 기다리고 있습니다.