Wireframe

vim9 스크립트에서 달라진 점

요 며칠전 vim 9.0 정식 버전이 사전 예고도 없이 갑작스럽게 릴리즈되었습니다. 뭐 예전부터 따로 베타 릴리즈 같은 걸 했던 코드는 아니어서… 오랜만의 메이저 릴리즈라 그런지 여기 저기서 많은 소식이 들려오고 있습니다. 개인적으로는 vim 8.0에서 새로운 기능은 더 많이 도입됐던 것 같은데, 이번에는 vimscript의 개선이라는 정말 거창한 주제 하나로 메이저 업데이트를 시행해서인지 주변에 vim을 쓴다는 사람들은 다들 관심을 보이는 것 같습니다. 사실, vim 8.2를 유지하던 시점에서도 vim의 새로운 스크립트 처리 관련 패치는 꾸준하게 이뤄지고 있었습니다. 즉 소스를 계속 받아서 빌드해본 사람이라면 오히려 9.0 릴리즈가 그렇게 새로운 것인가… 하고 약간 어리둥절해지는 그런 기분이 듭니다.

vim 8.0 이전부터 vimscript의 성능 개선에 대한 요구는 굉장히 많았습니다. vim 8.0 에 대거 추가된 많은 기능들은 사실, 사용자들로부터 투표를 받아서 채택한 기능들입니다. 이 때에도 vim script의 성능 개선 요구는 굉장히 많았지만, 그러한 니즈가 Bram 자신에게도 있었고, 아마도 그는 단순한 성능 개선 이상의 무엇인가를 원했던 것 같습니다. 그래서 다른 스크립트 언어의 런타임을 내장하면서 기존 vim script를 다른 언어로 컴파일하는 Neovim 과 같은 전략을 채택하는 대신, vim9script 라는 새로운 문법의 스크립트를 도입하는 것을 선택했습니다.

이 선택에 대해 알려졌을 때 reddit과 같은 커뮤니티등에서는 한바탕 난리가 났었습니다. 어차피 vim 외에서는 아예 쓸 일도 없는 스크립트를 새로운 문법을 더 만들어서 또 배워야 하게 만드는가? 차라리 파이썬이나 루아를 도입하면 안되냐 등등…어쨌든 그런 요구도 적지 않았지만, 개인적으로는 Bram의 의도는 이해합니다. Lua 같은 언어로 vim API를 사용하는 것이, 정말 set, map, command 같은 명령을 직접 사용하는 것보다 직관적이고 편리할지 잘 모르겠거든요. Lua가 됐든 파이썬이 됐든 해당 언어를 잘 알고 있는 것과, API가 디자인된 방식에 익숙해져 있는 것은 완전히 다른 문제이니까요.

그리고 아직까지는 좀 지켜봐야 할 소지가 있습니다. vim의 새로운 스크립트가 정말 모든 곳에서 사용될 수는 없기 때문입니다. 일단 새로운 문법을 사용하는 모드는 스크립트 파일 단위로 작동하기 때문에, 아직은 vim의 명령 모드에서는 직접 실행할 수가 없는 문법입니다. 아마도 Bram은 단순한 성능 개선 외에도 언어 디자인 자체를 고치고 싶었던 것 같습니다.

이미 vim 8.2를 내놓고 얼마 안된 시점에 vim9script를 발표했었기 때문에, 사실 vim 8.0 개발 당시에도 스크립트 성능 개션에 대해서도 고민과 테스트를 많이 했던 것 같습니다. 어쨌든 그 전부터 예상했던 대로 레거시 스크립트의 성능은 코드의 파싱과 해석에 가장 많은 시간을 사용했고, 매 라인은 매 번 실행될 때마다 이 과정을 거쳐야했습니다. 그래서 함수를 미리 바이트 코드 등으로 컴파일해놓고 사용하는 것이 성능 개선의 핵심적인 아이디어가 되었습니다. 그 외에 매번 값의 타입을 파악하기 위한 오버헤드를 없애는 등의 조치가 필요했을테구요. 그래서 불가피하게 이전 버전 스크립트와는 호환이 안되는 부분이 예상되었고, 결국 기왕 이렇게 된 것 손 보고 싶은 부분을 다 손을 보자…고 한 것이 오늘날의 vim 9 이 아닌가 싶습니다.

새로운 vim 9 버전의 스크립트는 개발 당시 시점부터 미리 vim9script 라고 불렸습니다. vim9script에서 새롭게 달라지는 부분은 대략 다음과 같은 것들이 주요한 내용입니다.

그 외에 모든 스크립트가 스크립트 파일 레벨에서 작동하는 것과 다른 스크립트를 import 할 수 있게 되면서 autoload 스크립트를 작성하고 사용하는 방법이 달라진 것등이 있을 수 있겠네요.

vim9script의 새로운 함수와 문법

vim9 모드에서 def / enddef 명령을 사용해서 새로운 함수를 정의할 수 있습니다. vim9 모드에서 모든 함수외 변수는 기본적으로 스크립트 스코프의 범위를 가지며, 따라서 s: 라는 접두어는 더 이상 사용되지 않습니다. 대신 전역 범위의 함수는 만들 수 있고, 이 때에는 g: 를 붙일 수 있습니다. 변수에서도 전역변수, 버퍼로컬변수, 창로컬변수는 여전히 유효하고 이들에게는 기존과 같은 접두어가 붙습니다. 참, 함수에서 정의한 파라미터는 이제 함수 내부에서 그냥 로컬 변수처럼 사용하면 되며, 별도의 접두어 없이 사용합니다. 함수를 정의하는 방법과 관련해서는 다음과 같은 차이가 있습니다.

  1. 새로운 함수의 정의는 def .. enddef 블록을 사용한다. vim9 모드에서도 기존의 function 명령을 사용해서 여전히 함수를 정의할 수 있지만, 이렇게 정의된 함수는 vim에 의해 컴파일되지 않고 기존과 같이 한줄 한줄 실행되어 성능 향상의 혜택을 받지 못한다.
  2. s: 접두어를 더이상 사용하지 않으므로, 함수 이름은 이제 항상 대문자로 시작한다. 단, 전역범위의 함수는 여전히 g: 접두어를 사용하긴한다.
  3. 파라미터가 있다면 타입을 명시적으로 지정해야 한다. 리턴값이 있다면 역시 명시적으로 지정해야 한다.
  4. range, abort, closure, dict 등의 함수에 부여되는 속성은 없어진다. 함수는 더 이상 range와 같이 작동하지 않으며, 사전에 묶일 수 없다. (대신 class가 조만간 추가된다고 한다.) 중간에 에러가 발생하면 즉시 실행을 중단하며, 모든 함수는 기본적으로 클로저가 될 수 있다.

정의한 사용자 함수 및 내장 함수를 호출할 때에는 더 이상 call 명령을 사용하지 않아도 됩니다. 함수를 호출하는 모양의 코드는 함수 호출로 평가됩니다.

변수와 구문에 대한 문법도 변경됩니다. 변수 역시 함수와 마찬가지로 기본적으로 스크립트 로컬로 선언되며, s: 접두어는 사용되지 않습니다. 여전히 g:, b:, w: 접두어를 사용하여 전역변수, 버퍼 및 창 로컬 범위의 변수를 사용하는 것은 가능합니다. 함수 파라미터의 경우에도 a: 접두어를 사용하지 않습니다. 접두어가 붙지 않는 함수는 최초에 정의할 때 var 키워드를 사용하여 정의하며, 이후 값을 대입할 때에는 더 이상 let 명령을 사용하지 않아도 됩니다.

참고로 if, for, while 등의 블럭 구문 내부에서 선언된 변수는 해당 블럭 내에서만 통용되며, 블럭을 벗어나면 파괴됩니다. 변수의 타입에는 다음과 같은 것들이 있습니다.

간단하게 입력 받은 두 수를 더해서 리턴하는 함수를 정의하고 사용하는 스크립트 예제입니다. 간단한 내용이라 달리 코멘트 할 부분은 없지만, 전체적인 모양이 여느 프로그래밍 언어와 비슷해 보이네요.

vim9script

def AddNums(a: number, b: number): number
    return a + b
enddef

var c = AddNums(3, 5)
echom c
c = AddNumbs(10, 30)
echom c

블럭

여러 줄의 코드를 하나의 구문처럼 사용하기 위해서 {...} 으로 범위를 둘러싸 블럭을 만드는 문법이 지원됩니다. 특히 이 문법은 주로 사용자 정의 명령이나, autocmd 명령을 정의할 때 유용하게 사용할 수 있습니다. 함수를 따로 정의하지 않더라도 두 개 이상의 명령을 순차적으로 실행하는 블럭을 하나의 표현식처럼 보는 것입니다. 중괄호로 둘러싼 영역 역시 블럭이므로, 이 내부에서 정의한 변수는 블럭을 빠져나오는 시점에 파괴됩니다.

command -range Rename {
            var save = @a 
            @a = 'some expression'
            echo 'do something with ' .. @a 
            @a = save
        }

au BufWritePre *.go {
            var save = winsaveview()
            silent! execute ':%! some formatting command'
            winrestview(save)
        }

사전

자바스크립트 객체와 비슷하게 사전 키는 기본적으로 문자열로 취급되어, 따옴표를 쓰지 않고도 정의할 수 있습니다. 만약 어떤 표현식의 평가값을 키로 사용하고 싶다면 대괄호로 묶어서 [ "apple" .. myvar ] 와 같은 식으로 사용하면 됩니다. 또한 키와 값을 구분하는 콜론의 뒤에는 반드시 공백이 있어야 해요. vim9script는 레거시 스크립트와 달리 몇몇 위치에서 공백을 중요하게 여깁니다.

람다식

람다식 문법도 자바스크립트의 arrow 함수처럼 변경됐습니다. (arg: {type}) : {type} => EXPR) 의 형식을 기본으로 하며, 이 때 화살표의 앞/뒤로는 공백이 있어야 합니다. 람다식은 기본적으로 하나의 표현식이지만, { ... } 블럭을 사용하여 여러 개의 명령문을 순차적으로 실행하도록 할 수도 있습니다.

def CompleteConfigFiles(leading: string, cmd: string, cpos: number): list<string>
    return globpath(folders, "**/*.vim", v:true, v:true)
           ->filter((i, v): bool => fnamemodify(v, ':t') =~? leading)
enddef

실행 관련하여 달라지는 점

기존 레거시 모드에 정의된 함수는 매 라인별로 실행됐고, 도중에 오류를 만나더라도 다음 라인을 계속해서 실행했습니다. (따로 abort 옵션을 주고 함수를 정의하지 않는다면요) vim9 함수는 최초 실행시에 컴파일을 하는데 이 때 만약 에러가 발생하면 이후 라인을 실행하지 않고 컴파일에 실패했다고 알려줍니다.

문제는 이렇게 컴파일에 실패한 함수는 실행이 불가능하지만, 스크립트 이름 공간에는 함수의 이름이 여전히 존재하기 때문에 exists() 함수를 사용해서 함수를 체크하는 것으로는 해당 함수가 실행 가능한지 여부를 미리 알 수 없다는 점입니다. 따라서 vim9 함수의 준비 여부를 확인할 때에는 exists_compiled() 를 사용해서 컴파일 완료 여부를 확인하는 것을 사용합니다. 참고로 exists() 와 마찬가지로 함수 이름 앞에 * 를 붙여야 하며, 변수 등의 다른 이름에 대해서는 작동하지 않습니다.

export / import

함수와 변수가 기본적으로 스크립트 범위로 제한되지만, import 명령을 사용하여 다른 스크립트 파일에서 정의한 함수나 변수를 사용할 수 있습니다.. 함수나 변수를 정의할 때 외부에서 보이게 하고 싶다면 export def, export var 명령을 사용해서 정의하면 됩니다. 다른 스크립트 파일에서 정의한 함수를 사용할 때에는 impot 명령을 사용하며 다음과 같은 방식으로 씁니다.

import "myscript.vim"
import "myscript2.vim" as that

반입된 파일은 하나의 사전 비슷하게 취급됩니다. 주어진 파일 이름에서 무조건 확장자를 떼고 myscript.MyFunc() 와 같이 호출하여 사용할 수 있습니다. 만약 주어진 이름이 .vim 으로 끝나지 않았다면 이 부분에서 오류가 발생하기 때문에 as 절을 사용해야 합니다.

반입할 파일 경로는 기본적으로 runtimepath 내에 있는 것으로 간주됩니다. 현재 스크립트와 같은 위치에 있는 파일을 사용하려면 “./” 으로 시작하는 상대경로나 “/“, “D:/” 와 같이 시작하는 절대 경로를 사용하여야 합니다. 특히 많은 플러그인을 주입하여 runtimepath 값에 많은 디렉토리가 등록되어 있다면, 해당 파일을 찾는데 시간이 오래 걸리기 때문에 가급적이면 특정한 위치를 지정해주는 것이 성능에 도움이 됩니다.

한 번 import 된 파일의 내용은 캐시되며, 이후에 다시 같은 파일을 import 하더라도 실제로 파일을 다시 읽지는 않는다.

vim9 에서는 autoload 스크립트도 이와 비슷하게 import를 기반으로 작동합니다. import autload 라고 모듈을 반입하면 autoload 디렉토리 하위에서 파일을 찾고, 그 파일 내에서 함수를 찾습니다. 이 때에도 동일하게 “filename.MyFunc()” 와 같은 식으로 사용하면 됩니다. 그러니까 사실상 어느 디렉토리를 뒤져보느냐만 다르지 일반적인 import와 import autoload는 별다른 차이가 없는 것처럼 보입니다.

autoload 디렉토리 내에 정의한 vim9 함수는 export 키워드를 사용해서 정의했다면, 레거시 vim 스크립트에서는 이전의 autoload 함수와 똑같은 방식으로 pathname#filename#FuncName() 과 같은 방식으로 접근할 수 있습니다. 굳이 이전처럼 pathname#filename#Funcname() 과 같이 파일 경로명을 정확하게 맞추려고 애쓰지 않아도 됩니다. vim9 모드에서 함수 이름을 이런식으로 지으면 사용할 수 없는 문자를 썼다고 에러를 내게 됩니다.

import 한 함수는 여전히 그 자신을 정의한 스크립트 내의 범위에 있습니다. 반입한 함수를 키맵에서 사용하려고 할 때가 문제가 되는데, 키 맵에서 사용하려고 할 때에는 <ScriptCmd> 키를 앞에 붙여주면 덧붙여야 할 다른 내용 없이 바로 사용할 수 있게 됩니다. 주로 autoload 스크립트 내에 정의한 함수를 이런식으로 많이 사용하겠죠.

vim9script
import autoload 'implemenation.vim' as impl
nnoremap <F4> <ScriptCmd>impl.DoTheWork()<CR>

기존 스크립트의 함수를 vim9로 포팅하기

기존에 작성해놓은 스크립트가 있다면 vim9 으로 전환했을 때 실제로 성능향상이 체감될 수준으로 빨라집니다. 전환은 다음과 같은 내용을 확인하면 됩니다.

  1. 스크립트의 첫 줄에 vim9script 를 추가합니다.
  2. function, endfunctiondef, enddef로 변경합니다.
  3. 함수의 인자에 각각 타입을 지정합니다. 리턴 값이 있다면 리턴값도 명시합니다.
  4. 코멘트는 따옴표가 아닌 # 으로 시작하도록 변경합니다. (# 앞에는 반드시 공백이 필요합니다.)
  5. 함수 내부의 파라미터를 참조하는 곳에서 a: 를 제거합니다.
  6. letvar로 변경합니다. 단 한 번 정의했거나, 전역, 버퍼, 창 범위 변수는 let을 모두 제거합니다.
  7. 문자열 결합 연산자를 “.” 에서 “..” 으로 변경합니다.
  8. 대입 및 산술 연산에서 각 연산자 앞뒤로 공백을 추가해줍니다.

Exit mobile version