Wireframe

vim script 함수 총정리

vim에서 자주 사용하는 간단한 매크로는 키 맵으로 정의해서 사용할 수 있다. 하지만 여러 명령을 결합하거나, 데이터를 조작하거나 상황에 따라서 분기해야하는 등의 좀 더 복잡한 동작을 구현하기 위해서는 함수가 필요하다. vim script에서도 함수를 지원하고 있어서 이를 활용할 수 있는데, 오늘은 함수를 정의하고 사용하는 여러 방법에 대해서 정리해보도록 하겠다. 가능하면 이 포스트 하나로 vim 스크립트에서 함수를 정의하고 사용하는 것과 관련된 내용은 모두 다뤘으면 한다. (그리고 이후에 vim script 관련 포스팅을 하더라도 참고용으로 긴 설명 글이 필요하기도 하고…)

function 명령

다른 프로그래밍 언어와 비슷하게 vim에서도 함수를 정의하는 방법이 있다. 가장 최소한의 내용만 표현하면 다음과 같은 문법으로 이루어진다.

function {사용자정의함수 이름}({인자들})
    ... 함수 내용
endfunction

function ... endfunction 사이의 블럭으로 함수를 정의하는 것은 다른 여러 프로그래밍 언어와 비슷한 문법처럼 보인다. 그런데 특이한 것은 여기서 사용하는 function이나 endfunction의 경우, :execute, :normal 등과 같은 vim의 Ex 명령이라는 점이다. 이 명령은 기본적으로 함수를 선언하며, 이후에 입력되는 명령들을 모두 순서대로 해당 함수에 추가한다. 이 동작은 :endfunction 명령을 만날 때까지 유지된다. 이런 방식으로 vim 스크립트는 함수를 생성한다. 즉, vimscript의 모든 라인은 각각이 : 를 맨 앞에 붙여도 되는 Ex 명령이라는 점을 잊지 말자.

함수 이름

함수의 이름은 vim 전체에서 고유해야 한다. :function 명령은 이미 존재하는 이름의 함수를 다시 정의하려고 하면 에러를 내면서 실행을 멈춘다. 그리고 사용자가 정의하는 함수의 이름은 “사용자 정의 명령”과 마찬가지로 영어 대문자로 시작해야 한다.

만약 동일한 함수가 있을때, 기존 함수를 무시하고 새로 정의하려면 :function! 과 같이 뒤에 따옴표를 붙여서 실행하면 된다. (이 점은 다른 vim Ex 명령과 어느 정도 공통점이 있다.) 사실 함수의 이름은 “동일한 스코프” 내에서만 구분되면 되는 것이라서 이 구분을 사용하는 테크닉이 존재한다. 함수의 스코프에 대해서는 조금 있다가 다시 정리하겠다.

인자

vim 스크립트에서도 함수의 인자에 대해서는 어느 정도의 다양한 옵션을 주고 있기 때문에 정말 쓸모있다는 생각이 든다. 함수 인자는 함수 이름 뒤에 (인자들) 의 형태로 사용한다. 이 때 몇 가지 규칙은 다음과 같다.

흥미로운 점은 vim 스크립트에서 함수의 인자는 함수 내부의 지역 변수가 아니라 “인자 변수”라는 별도의 분류로 구분된다. 따라서 인자를 함수 내부에서 참조하면 a: 라는 접두어가 항상 따라 붙게 된다. (생략하면 에러…) 만약 function! MyFunction(type='') 과 같은 식으로 함수를 정의했다면 매개 변수 “type”은 함수 내에서 a:type 이라고 써야 한다는 것이다.

가변인자를 사용한 경우 각각의 인자는 a:1, a:2 와 같이 1로 시작하는 이름으로 참조한다. 사실 가변인자로 받아들인 함수들은 사실 하나의 리스트 형태로 전달된다.

함수의 선언 옵션

:function 명령은 함수이름, 인자 외에도 다른 몇 가지 매개 변수가 사용될 수 있다. abort, dict, range, closure 같은 옵션들이 존재한다. 각각의 추가인자는 독립적인 옵션이며 조합되어 사용될 수 있다. 각 옵션의 의미는 다음과 같다.

  1. abort : 실행 시 에러를 만나면 그 지점에서 중단하고 이후 부분을 실행하지 않도록한다. 특정한 계산 결과에 따라 버퍼의 내용을 변경하는 함수라면 이 옵션을 사용하는 것이 좋다. (거의 디폴트로 사용한다고 보면 된다.)
  2. range : 함수를 실행할 때 :call 명령을 사용하게 되는데, 이 때 call 앞에는 범위를 지정할 수 있다. (이때의 범위는 linewise하다) range 옵션을 사용한 경우, 선언된 인자와 별개로 a:firstline, a:lastline 의 이름으로 범위의 시작 줄과 끝 줄 번호를 참고할 수 있다.
  3. dict : 함수가 사전의 속성값이 되도록 정의한다. 조금 특수한 케이스가 되는데 이 옵션이 붙은 채로 생성된 함수는 일반적인 방식으로는 호출되지 않으며, 사전(Dictionary)의 엔트리로 접근된 형식일 때에만 작동하게 된다. 또, 함수 내부에서는 self 를 사용해서 그 사전을 참조할 수 있다. vim 스크립트에는 이를 통해 간접적으로 OOP를 사용하게 한다.
  4. closure : 함수가 클로저가 되도록 한다. 다른 언어에서 말하는 그 클로저가 맞다. 이 옵션을 가지고 정의된 함수는 함수가 정의되는 시점에 내부에서 참조한 변수를 외부 스코프로 가져갈 수 있다.
range 함수 예제
:function MyNumber(arg)
:  echo line('.')..' '..a:arg
:endfunction
:1,5call MyNumber(getline('.'))

위 코드는 간단한 함수를 정의하고 이를 실행할 때 범위와 함께 실행했다. 기본적으로 line('.') 은 커서가 위치한 현재줄의 번호가 되지만, 호출할 때 범위를 지정하여 호출했다면, 범위 내의 각 라인이 된다. 즉 :1,5call MyNumber(getline('.')) 는 1행부터 5행의 5 줄에 대해서 각 라인마다 MyNumber(getline(‘.’)) 을 실행하라는 것과 같은 의미로 작동하게 되어 실제로는 5번 호출된다. 하지만 range 옵션이 붙어있는 함수는 한 번만 작동한다. 암묵적으로 주어진 범위의 양 끝 줄이 변환되어 전달되는 식이다. 아래는 range 옵션을 사용했지만, 내부에서도 범위를 적용하여 Ex 명령을 실행하는 방식으로 주어진 범위 전체에 그 효과가 나타나도록 구현한 예제이다.

:function Cont() range
:  execute (a:firstline + 1)..','..a:lastline..'s/^/\t\\ /'
:endfunction
:4,8call Cont

참고로 위 두 함수 예제의 각 라인 앞에는 콜론(:)이 붙어 있는데, 별도의 vim 스크립트 함수를 작성하지 않고 명령줄에서 그대로 함수를 작성할 수 있다. 앞서 :function 명령은 :endfunction 을 만날 때까지 이어지는 명령을 함수의 코드로 인식한다고 했다. 따라서 명령줄 모드에서 function 명령을 쓰고 엔터를 치면 다음 줄에 대한 입력을 계속 기다리는 모드로 남아있게 된다.

dict 함수 예제

사실 dict 옵션을 사용해서 함수를 사전의 값으로 사용하는 것은 제법 불편하다. 일단 다음 예제를 보자.

:function Mylen() dict
:  return len(self.data)
:endfunction

:let mydict = {'data': [0, 1, 2, 3], 'len': function("Mylen")}
:echo mydict.len()

“dict” 옵션을 사용해서 독립된 자유 함수를 하나 정의하고, 사전 “mydict”를 생성하면서 “len”키에 대한 값으로 이 함수에 대한 참조(Funcref)를 지정했다. 여기서 function() 함수는 :function 명령과는 다르다. 이 함수는 문자열로 된 함수의 이름을 받아서 그런 이름을 가진 함수를 찾아 해당 함수에 대한 참조를 리턴해준다.

그런데 이런 방식으로 정의하는 것은 사실 좀 지저분하고, 함수 이름을 낭비하는 셈이 되기 때문에 다른 개선책이 나왔다.

:let mydict = {'data': [0, 1, 2, 3]}
:function mydict.len()
:  return len(self.data)
:endfunction
:echo mydict.len()

아예 함수를 정의할 때, 그 이름을 {dict_name.key} 의 형식으로 정해버리면 해당 사전에 대해 함수 참조 객체값으로 새 엔트리를 생성해준다. 그래서 결론은 dict 옵션은 쓰지 않아도 좋겠다..

클로저 예제
:function! StepGenerator()
:  let x = 0
:  function! Step() closure
:    let x += 1
:    return x
:  endfunction
:  return funcref('Bar')
:endfunction

여기서는 함수 자체를 반환하기 위해서 funcref() 함수를 사용했다.function() 함수와 funcref() 함수는 사용 방법이 비슷한데, function() 의 경우에는 이름만으로 함수를 찾는 것이고 funcref 는 함수에 대한 참조로 찾는다. 이 둘은 겉보기에는 거의 같은 함수처럼 보이지만, 함수의 이름이 바뀌는 경우에도 같은 함수를 참조하기 위해서는 funcref()를 써야 한다. 위 예제의 아우터 함수는 이너함수인 “Step()”의 참조를 외부로 넘기고 그 외부에서는 모종의 어떤 변수, 즉 다른 이름을 부여할 것이기 때문이다.

함수에 대한 참조를 저장할 변수는, 호출할 때의 문법 규칙에 의해서 함수와 동일한 이름의 규칙을 적용받아야 한다.

:let S = StepGenerator()
:echo S()
1
:echo S()
2

함수의 스코프

변수에 여러 스코프가 있는 것과 비슷하게 함수도 스코프가 있다. 대신 변수만큼 다양하지는 않다. 모든 함수는 기본적으로 전역이며, 버퍼 전용 함수는 존재할 수 없다. 따라서 함수는 전역 함수이거나, 스크립트 스코프 함수이거나 둘 중 하나의 스코프를 적용 받는다. 대문자로 시작하는 이름의 일반적인 사용자 정의 함수는 모두 전역함수이다. 스크립트 스코프 함수는 이름 앞에 s: 가 붙는다. (이렇게 접두어를 붙이면 함수이름은 소문자로 써도 상관없다.)

전역 스코프 함수에 대해서는 따로 설명할 필요는 없을 것 같다. 스크립트 스코프 함수는 해당 함수를 함수가 선언된 같은 스크립트 파일 내에서만 참조가 가능하다. 변수의 참조와 마찬가지로 같은 파일 내에 있는 스크립트 범위 함수는 호출할 때에도 앞에 s: 를 붙이면 된다.

함수 호출 방법

함수를 호출하는 기본적인 방법은 :call 명령을 사용하는 것이다. 중요한 대전제는 모든 vim 스크립트의 각각의 라인은 vim의 Ex 명령이라는 것이다. 그리고 함수는 어떤 “표현식의 세계”에 있는 것이다. 따라서 일반적인 프로그래밍 언어처럼 함수를 호출하기 위해서 빈 줄에 Myfunc() 만 덜렁 써서는 안된다. 기억하자. 모든 라인은 암묵적으로 : 으로 시작한다. 따라서 스크립트에서는 :를 생략하면 항상 vim의 Ex 명령으로 시작해야 하는 것이다. 스크립팅에서 자주 쓰이는 let, if, for, while 등이 다른 언어처럼 statement를 만드는 문법 요소가 아니라 모두 Ex 명령이다.

: call  MyFunc() 
" Ok.
: let x = Func1() + Func2()
" let 으로 시작했으니, = 우측의 함수는 평가의 대상이 된다. 
: if MyCriteria()
:    call DoSomething()
: endif
" 블럭의 들여쓰기는 사실상 장식일뿐이다. 함수 단독으로 평가되는 라인에서는 call을 쓴다.

그런데 스크립트 스코프의 함수(로컬 함수)는 어떨까? 보통 vim에서 함수를 사용하는 경우는 다음과 같다.

  1. 해당 함수를 직접 호출하는 명령을 :command 로 만들어 사용한다.
  2. 특정 키 맵에 함수를 호출하는 명령을 지정하여 사용한다.
  3. 명령모드에서 직접 :call MyFunc() 를 입력하여 호출한다.

일단 로컬 함수의 경우에는 명령모드에서는 보이지 않으므로 사용할 수 없다. 그렇다면 해당 명령을 나중에 호출할 수 있는 방법은 위에서 1, 2의 두 가지이다. 로컬 함수가 호출될 수 있는 위치는 1) 다른 함수 내부 2) 같은 스크립트 내 :command 명령, 3) 같은 스크립트 내 :autocmd 명령이다.

스크립트 함수에 대한 키 맵

키 맵의 경우에는 상황이 조금 복잡하다.

function! s:myfunc()
    echom "hello"
endfunction

nnoremap <leader>h :call s:myfunc()<CR>

키 맵은 실제로는 매크로나 다름없기 때문에 스크립트 외부에서 이 키 맵을 실행하면 정의되지 않은 이름이라는 오류가 난다. 키 맵을 통해서 로컬 함수를 실행하려면 다음과 같이 선언한다.

nnoremap <leader>h :call <SID>myfunc()<CR>

<SID> 는 아마도 “Script ID”를 말하는 것 같은, vim 키맵에서의 특수한 키이다. 이 키는 실제 물리적인 키보드 키에 맵핑되는 것이 아니라, 해당 맵이 선언되는 스크립트 파일의 식별값으로 변환된다. 이것은 사실 로컬 함수가 작동하는 방식 그 자체이다. 사실 vim 스크립트에는 함수 이름에 대한 네임스페이스가 따로 구분되어 있는 것이 아니라, 스크립트의 식별값이 함수 이름에 접두어로 붙어서 다른 스크립트 내의 로컬 함수와 이름이 중복되지 않도록 하는 것이다.

실제로 이 <SID> 값은 <SNR>123_ 과 같은 형태의 내용으로 변경된다. 스크립트 파일의 식별자는 vim이 스크립트 파일을 소싱하는 시점에 결정되며, vim을 실행할 때마다 달라질 수 있다. 어쨌든 스크립트 파일 내에서 로컬 함수를 호출하는 키맵을 작성한다면 s:<SID>로 바꿔주면 된다는 것만 기억하면 되겠다.

플러그인에서 키 맵을 정의하는 방법

방금, 스크립트 파일 내에서 외부에서 쓸 키 맵을 정의하는 방법에 대해 소개하였다. 하지만 그 내용은 순전히 <SID>의 의미를 설명하는 글이지, 실제로 플러그인에서 키맵을 정의하는 방식 자체에 대한 내용은 아니라고 봐야한다. 어떤 스크립트를 단순히 하나의 vimrc 파일이 너무 커지는 것 때문에 여러 개의 파일로 분할하였고, 그래서 재사용/재배포를 하지 않는 것을 전제로 했다면 이런 생각을 할 필요가 없을 것이다. 만약에 내가 어떤 스크립트를 만들었는데, 나는 <F8> 키를 눌러서 그 기능을 실행하고 싶었기 때문에 다음과 같이 키 맵을 작성했다고 하자.

nnoremap <slient> <F8>   <Cmd> call <SID>run_script_in_terminal()<CR>

그리고 나는 아마 이 기능이 무척이나 마음에 들었을테고 주변 사람들에게 이것을 자랑할 것이다. 그러던 중 여러분이 그 기능이 꼭 필요해서 스크립트를 받아서 여러분의 환경에 설치했다고 하자. 그런데 문제는 나는 그 이전에 <F8> 키를 키맵으로 사용한 적이 없지만, 여러분 중 누군가는 이미 사용하고 있는 키맵일 수도 있다는 것이다. 뭐 나에게 꼭, 간절하게 필요한 기능이라면 다소 수고스럽고 (또 다시 익숙해져야 하니 불편하겠지만) <F8> 키를 이 스크립트에게 양보할 수 있을 것이다. 아니면 스크립트를 직접 수정해서 다른 키로 맵핑을 바꿔 줄 수 있을지도 모르겠다.

그런데 이러한 플러그인들이 여러 개라면 어쩔까? 여러 개의 플러그인이 똑같은 키에 대한 키 맵을 미리 정의해버리고 있다면 사용하는 사람 입장에서는 너무 난감한 일이 될 것이다. 만약 내가 지금 작성하고 있는 스크립트를 다른 사람에게도 배포할 생각이 있다면, ‘지금 내가 여기서 정의하려는 키 맵을 이미 누군가는 사용하고 있을지도 모른다’ 라는 가정을 해야 한다.

이 문제를 적절하게 해결할 수 있는 방법이 있을까? 저 가정이 곧 문제인데 그 문제가 답이 된다. 누군가 이미 점유하고 있을지 모르는 키를 사용하게 될까봐 문제가 된다면, 아무도 키맵으로 사용하지 않을 키를 키맵에 포함시키면 되는 것이다. 만약 내가 🍟이라는 키에 이 기능을 할당했다고 하자. 하지만 다른 키맵과 절대로 간섭되지 않게, 이 감튀는 키보드로는 절대 입력할 수 없는 문자라고 가정하자. 그리고 내 스크립트를 가져가려는 사람들에게 알려준다. “이 기능은 ‘감튀 이모지’에 맵핑되어 있어요.”

" in MyScript.vim
nnoremap <script><silent> 🍟  <Cmd> call <SID>run_script_in_terminal()<CR>

그런데 저 감자튀김을 실제로 키보드로는 바로 입력할 수가 없다. 그래서 이 스크립트를 가져다 설치한 다른 누군가는 자신이 원하는 키로 ‘리맵핑’하여 사용할 것이다. 누군가는 <F4>를 다른 누군가는 <C-r><C-t> 를 맵핑할 수도 있을 것이다. 이제 모두가 해피해진다.

" in any other's vimrc
nmap <F4> 🍟

그런데 문제는 저 감자튀김을 실제로 키보드로 입력할 수 없다는 것이다. 키보드로 입력할 수 없는 문자를 어떻게 키보드로 입력해야할까? 바로 <SID>처럼 스크립트 파일이 로드될 때 다른 값으로 바뀌는 특수한 키를 하나 임의로 만들면 되는 것이다. 그렇게 정의되는 키가 바로 <Plug>이다.

nnoremap <silent> <Plug>(build-helper)  <Cmd> call <SID>run_script_in_terminal()<CR>

<SID>와 <Plug> 는 도입된 목적도 비슷하고 사용법도 비슷한데 둘의 차이점은 SID는 외부로 노출되지 않는 것을 전제로 하고 있지만 <Plug>는 외부로 노출되는 것을 목적으로 삼는다는 것 정도가 되겠다. 또 참고로 <Plug>로 시작하는 맵핑은 외부에서 사용할 때에는 항상 리맵핑하여 호출하기 때문에, 플러그인의 사용자들은 *noremap 을 사용하지 않아야 한다는 점 정도는 유의해야 겠다.

함수 참조

vim 스크립트에서 함수는 퍼스트 클래스가 아니다. map(), filter() 등과 같이 함수를 인자로 받는 함수들은 있었지만, 이들 함수의 인자로 함수를 그대로 넘겨줄 수는 없었다. 예전에는 이런 함수들에 대해서 “‘각 원소를 처리하는 표현식’을 문자열로 표현한 것”을 넘겼다.

" xs의 원소 중에서 양수만 걸러내기
let ys = filter(xs, 'v:val > 0')

하지만 보다 복잡한 식이나 여러 식을, 즉 별도의 함수를 사용해야 하는 경우를 지원하기 위해서, 함수에 대한 참조인 Funcref가 추가되었다. 함수에 대한 참조는 다음 세 가지 방식을 통해서 만들 수 있다.

이렇게 생성된 함수 참조는 함수를 하나의 표현식으로 취급할 수 있게 하는 동시에, 그 자체에 (...) 를 사용해서 해당 함수를 호출할 수도 있다.

function(), funcref() 함수는 둘 다 함수의 이름으로부터 그 이름을 가진 함수를 찾고 찾아낸 함수에 대한 참조값을 리턴한다. 좀 더 복잡한 예를 만들어보았다. 우박수열의 길이 50 이내에서 1이 되는 수들만 필터링하는 예시이다. 이러한 조건식은 간단한 표현식으로 작성하기 어렵기 때문에, 필수적을 별도의 함수 정의가 필요하다.

참고로 map(), filter() 함수에 함수 참조를 객체로 넘길 때에는 두 개의 인자를 받도록 함수를 선언해야 한다. (인덱스, 값) 의 쌍의 형태로 함수에 전달되기 때문이다.)

function! NumHail(idx, value)
	let n = a:value
	let c = 50
	while n > 1 && c > 0 
		let c -= 1
		if n % 2 == 0 
			let n = n / 2
		else
			let n = n * 3 + 1
		endif
	endwhile
	return c > 0 ? 'yes' : 'no'
endfunction

echo filter(range(100), function('NumHail'))

함수 참조값은 그 외에도 변수에 대입하거나, 사전 엔트리의 값이 되거나 하는 등에 사용될 수 있다. 이 때 로컬 함수를 참조하는 경우에는 정직하게 s: 를 붙여서 쓰면 된다.

함수 참조의 다른 용도 – partial 함수

function() 함수는 다른 함수에 대한 참조를 생성하는 것 외에도 다른 용도로도 활용이 가능한데, 실제로 function() 함수는 함수 이름 외에 주어질 인자를 받을 수 있다. 즉 일부 인자는 미리 지정해 놓을 수 있는 셈이다.

" function({name} [, {arglist}] [, {dict}])

function! Add(a, b)
  return a:a + a:b
endfunction

let Increase = function('Add', [1])
echo Increase(7)
" -> 8

람다 함수

간단한 표현식을 함수로 전달할 때 문자열 형태로 쓰는 것은 아무래도 제약이 많기 때문에, 이를 개선하기 위한 또 다른 방법으로 람다함수 혹은 람다표현식이라 불리는 것이 지원된다. 람다 함수는 다음의 문법으로 정의된다.

{ 인자들 -> 표현식 }

인자는 0개 이상의 인자의 이름을 콤마로 구분하여 입력한다. 표현식은 Ex 명령이 아닌 vimscript 표현식을 쓴다. 이렇게 만들어진 람다 함수는 함수보다는 실질적으로 하나의 표현식이고, 따라서 외부에서는 FuncRef 로 취급된다. 참고로 람다식에서는 일반적인 함수에서 인자를 다루는 것처럼 앞에 a: 를 붙이지 않는다. 또한 람다 함수는 함수 외부의 변수를 참조할 수 있고, 계속 캡쳐하여 유지하기 때문에 클로저의 성질을 그대로 갖는다.

메서드

내장 함수 중에는 ‘메서드’ 호출을 지원하는 것들이 있다. 메서드 호출은 함수의 첫번째 인자를 괄호가 아닌 함수의 앞부분에서 넘겨받도록 하는 것이다. 실제로는 함수와 함수 사이를 연결하여 앞에서 평가된 함수의 결과를 다음 함수의 첫번째 인자로 전달하는 것이다.

mylist->filter(expr1)->map(expr2)->sort()->join(', ')

이런 식으로 여러 함수가 순차적으로 연결될 수 있게 하기 때문에 여러 함수를 엮어서 하나의 표현식으로 만들기 좋아서 람다 표현식 내부에서 요긴하게 쓰일 수 있다. (물론 람다표현식 그 자체도 함수 객체로 볼 수 있으므로, 여기에 포함 시킬 수 있다.)

Exit mobile version