智能补全

智能补全是提升编码效率的杀手锏。试想下,有个函数叫 getCountAndSizeFromRemotefile(),当你输入 get 后 IDE 自动帮你输入完整的函数名,又如,有个文件 ~/this/is/a/deep/dir/file.txt,就像在 shell 中一样,键入 tab 键自动补全文件路径那是何等惬意!

智能补全有两类实现方式:基于标签的、基于语义的。

基于标签的智能补全

前面代码导航时介绍过标签,每个标签项含有标签名、作用域等等信息,当键入某几个字符时,基于标签的补全插件就在标签文件中搜索匹配的标签项,并罗列出来,你选择中意的,这与前面代码导航类似,一个是用于跳转、一个用于输入。基于标签的补全,后端 ctags 先生成标签文件,前端采用插件 new-omni-completion(内置)进行识别。这种方式操作简单、效果不错,一般来说两步搞定。

第一步,生成标签文件。在工程目录的根目录执行 ctags,该目录下会多出个 tags 文件;

第二步,引入标签文件。在 VIM 中引入标签文件,在 VIM 中执行命令

:set tags+=/home/your_proj/tags

后续,在编码时,键入标签的前几个字符后依次键入 ctrl-x ctrl-o 将罗列匹配标签列表、若依次键入 ctrl-x ctrl-i 则文件名补全、ctrl-x ctrl-f 则路径补全。

举个例子,演示如何智能补全 C++ 标准库。与前面介绍的一般步骤一样,先调用 ctags 生成标准库的标签文件,再在 VIM 中引入即可,最后编码时由相应插件实时搜索标签文件中的类或模板,显示匹配项:

首先,获取 C++ 标准库源码文件。安装的 GNU C++ 标准库源码文件,openSUSE 可用如下命令:

zypper install libstdc++48-devel

安装成功后,在 /usr/include/c++/4.8/ 可见到所有源码文件;

接着,执行 ctags 生成标准库的标签文件:

cd /usr/include/c++/4.8
ctags -R --c++-kinds=+l+x+p --fields=+iaSl --extra=+q --language-force=c++ -f stdcpp.tags

然后,让 OmniCppComplete 成功识别标签文件中的标准库接口。C++ 标准库源码文件中使用了 _GLIBCXX_STD 名字空间(GNU C++ 标准库的实现是这样,如果你使用其他版本的标准库,需要自行查找对应的名字空间名称),标签文件里面的各个标签都嵌套在该名字空间下,所以,要让 OmniCppComplete 正确识别这些标签,必须显式告知 OmniCppComplete 相应的名字空间名称。在.vimrc中增加如下内容:

let OmniCpp_DefaultNamespaces = ["_GLIBCXX_STD"]

最后,在 VIM 中引入该标签文件。在 .vimrc 中增加如下内容:

set tags+=/usr/include/c++/4.8/stdcpp.tags

后续你就可以进行 C++ 标准库的代码补全,比如,在某个 string 对象名输入 . 时,VIM 自动显示成员列表。如下图所示:

基于标签的 C++ 标准库补全基于标签的 C++ 标准库补全

再来看个例子,看看如何补全 Linux 系统 API。与前面的标准库补全类似,唯一需要注意,Linux 系统 API 头文件中使用了 GCC 编译器扩展语法,必须告诉 ctags 在生成标签时忽略之,否则将生产错误的标签索引。首先,获取 Linux 系统 API 头文件。openSUSE 可用如下命令:

zypper install linux-glibc-devel

安装成功后,在 /usr/include/ 中可见相关头文件;

接着,执行 ctags 生成系统 API 的标签文件。Linux 内核采用 GCC 编译,为提高内核运行效率,Linux 源码文件中大量采用 GCC 扩展语法,这影响 ctags 生成正确的标签,必须借由 ctags 的 -I 命令参数告之忽略某些标签,若有多个忽略字符串之间用逗号分割。比如,在文件 unistd.h 中几乎每个API声明中都会出现 __THROW、__nonnull 关键字,前者目的是告诉 GCC 这些函数不会抛异常,尽量多、尽量深地优化这些函数,后者目的告诉 GCC 凡是发现调用这些函数时第一个实参为 nullptr 指针则将其视为语法错误,的确,使用这些扩展语法方便了我们编码,但却影响了 ctags 正常解析,这时可用 -I __THROW,__nonnull 命令行参数让 ctags 忽略这些语法扩展关键字:

cd /usr/include/
ctags -R --c-kinds=+l+x+p --fields=+lS -I __THROW,__nonnull -f sys.tags

最后,在 VIM 中引入该标签文件。在 .vimrc 中增加如下内容:

set tags+=/usr/include/sys.tags

从以上两个例子来看,不论是 C++ 标准库、boost、ACE这些重量级开发库,还是 Linux 系统 API 均可遵循“下载源码(至少包括头文件)-执行 ctags 生产标签文件-引入标签文件”的流程实现基于标签的智能补全,若有异常,唯有如下两种可能:一是源码中使用了名字空间,借助 OmniCppComplete 插件的 OmniCpp_DefaultNamespaces 配置项解决;一是源码中使用了编译器扩展语法,借助 ctags 的 -I 参数解决(上例仅列举了少量 GCC 扩展语法,此外还有 _attribute_malloc _、__wur 等等大量扩展语法,具体请参见 GCC 手册。以后,如果发现某个系统函数无法自动补全,十有八九是头文件中使用使用了扩展语法,先找到该函数完整声明,再将其使用的扩展语法加入 -I 列表中,最后运行 ctags 重新生产新标签文件即可)。

基于语义的智能补全

对于智能补全只有轻度需求的用户来说,基于标签的补全能够很好地满足需求,但对于我这类重度需求用户来说,但凡涉及标签,就存在以下几个问题:

  • 问题一,必须定期调用 ctags 生成标签文件。代码在不停更新,每次智能补全前你得先手动生成标签文件,这还好,你可以借助前面的 indexer 在保存文件时更新标签文件;麻烦的是,你代码中要用到 C++ 标准库中的接口吧,那么事前你先得对整个标准库生成标签文件,这也还好,无非多个步骤;更昏人的是,你得使用了编译器语法扩展的库,你还得一条条找出具体使用了哪些扩展语言,再让 ctags 忽略执行语法关键字,真有够麻烦的;
  • 问题二,ctags 本身对 C++ 支持有限。面对函数形参、重载操作符、括号初始化的对象,ctags 有心无力;对于 C++11 新增 lambda 表达式、auto 类型推导更是不认识。

我需要更优的补全机制 —— 基于语义的智能补全。语义补全,实时探测你是否有补全需求,无须你定期生成标签,可解决问题一;语义补全,是借助编译器进行代码分析,只要编译器对 C++ 规范支持度高,不论标准库、类型推导,还是 boost 库中的智能指针都能补全。什么是语义分析补全?看下图:

语义分析补全语义分析补全

代码中定义的 TCandyBar 类型只包括 3 个成员,但 clang_complete 能补全编译器根据 C++ 规范自动添加的两个重载操作符、一个默认构造函数、一个析构函数,这就是基于语义分析的智能补全。要进行语义分析,编译器必不可少。Linux 上 GCC 和 clang 两大主流 C++ 编译器,基于不同编译器,开源社区分别创造出 GCCSense 和 clang_complete 两个语义补全插件,又得纠结选哪个 -。- ... 请跳转至“源码安装编译器 clang”部分做两件事,一是按介绍安装好最新版 clang 及其标准库,二是看明白 clang 相较 GCC 的优势 ...

我选 clang_complete,原因如下:

  • 使用难度低。clang 采用低耦合设计,语义分析结果(也就是 AST)能以接口形式供外围程序使用,无须任何调整,clang_complete 便能能轻松拿到 clang 输出的语义分析结果;而 GCC 采用高耦合设计,你必须结合补丁重新源码编译 GCC,才能让 GCCSense 接收到它的语义分析结果;
  • 维护时间长。clang_complete 最新更新为 13 年上,而 GCCSense 则是 09 年下;
  • 支持跨平台。clang_complete 支持所有平台,而 GCCSense 支持 UNIX-like,不支持 windows。(好啦,这点是我凑数的,我又不用 windows <_<)

clang_complete 使用简单,在 VIM 输入模式下,依次键入要补全的字符,需要弹出补全列表时,手工输入 tab。比如有如下代码片断:

string name_yang = "yangyang.gnu";
string name_wang = "wangwang";

我要补全这两个对象,可以先键入 n tab,这时 clang_complete 将罗列出所有以 n 开头的待选项,接着输入 ame_ 后剩下 name_yang 和 name_wang 两项,按需选择即可。

到这个节目眼上,我应该先给出 clang_complete 的下载地址,再告诉你如何配置 .vimrc,然后给个截图收工,但是、但是,你知道我是个纠结症+完美症重度患者,虽然 clang_complete 相较 ctags+new-omni-completion 的模式有了质的飞跃,仍有雕琢余地:

  • 无法随键而全。补全动作有感知,要弹出候选列表菜单还得键入 tab,我希望插件能自动感知我的补全需求;
  • 无法模糊搜索。上例中,键入的字符要是少了那要出来一堆待选项,选起来眼睛累,多键入几个字符倒是可以让选项少些,但输入多了手又累。这是由于 clang_complete 采用的顺序匹配算法,只要改用子序列匹配算法(模糊搜索算法的一种)即可搞定,这样,我键入 ny 就只出现 name_yang,键入 nw 出现 name_wang;
  • 无法高速补全。补全列表速度不够快,clang_complete 由 python 编写,生成补全列表的速度有一定影响,再加上整个补全动作是在 VIM GUI 主线程中执行,所以有时会导致 GUI 假死,我需要由静态语言编写插件内核、动态语言作为粘合剂的补全插件,提升效率;

什么叫所需即所获?就是当你需要什么功能,它就能给你什么功能。YouCompleteMe(后简称 YCM,https://github.com/Valloric/YouCompleteMe ),一个随键而全的、支持模糊搜索的、高速补全的插件,太棒了!YCM 由 Google 公司搜索项目组的软件工程师 Strahinja Val Markovic 所开发,YCM 后端调用 libclang(以获取 AST,当然还有其他语言的语义分析库,我不关注)、前端由 C++ 开发(以提升补全效率)、外层由 python 封装(以成为 VIM 插件),它可能是我见过安装最复杂的 VIM 插件了。有了 YCM,基本上 clang_complete、AutoComplPop、Supertab、neocomplcache、UltiSnips、 Syntastic 可以再见了。

要运行 YCM,需要 VIM 版本至少达到 7.3.598,且支持 python2/3,参照“源码安装编辑器 VIM”部分可满足;

YCM 含有与 VIM 交互的插件部分,以及与 libclang 交互的自身共享库两部分,整个安装过程如下:

第一步,通过 vundle 安装 YCM 插件:

Plugin 'Valloric/YouCompleteMe'

随后进入 VIM 执行 :PluginInstall。

第二步,下载 libclang。你系统中可能已有现成的 libclang(自行源码编译或者发行套件中自带的),最好别用,YCM 作者强烈建议你下载 LLVM 官网的提供预编译二进制文件,以避免各种妖人问题。在 http://llvm.org/releases/download.html 找到最新版 LLVM,Pre-built Binaries 下选择适合你发行套件的最新版预编译二进制文件,下载并解压至 ~/downloads/clang+llvm;

第三步,编译 YCM 共享库:

zypper --no-refresh se python-devel python3-devel boost-devel llvm-clang-devel
cd ~/downloads/ 
mkdir ycm_build 
cd ycm_build 
cmake -G "Unix Makefiles" -DUSE_SYSTEM_BOOST=ON -DPATH_TO_LLVM_ROOT=~/downloads/clang+llvm/ .\
 ~/.vim/bundle/YouCompleteMe/third_party/ycmd/cpp
cmake --build . --target ycm_core

在 ~/.vim/bundle/YouCompleteMe/third_party/ycmd 中将生成 ycm_client_support.so、ycm_core.so、libclang.so 等三个共享库文件;

按照惯例,我该介绍 YCM 的设置。

设置一,YCM 后端调用 libclang 进行语义分析,而 libclang 有很多参数选项(如,是否支持 C11 的 -std=c11、是否把警告视为错误的 -Werror),必须有个渠道让 YCM 能告知 libclang,这可以在 .vimrc 中增加一个全局配置,但我有多个工程,每个工程使用的 libclang 参数选项不同岂不是每次都要调整 .vimrc?!YCM 采用更具弹性的方式,每个工程有一个名为 .ycm_extra_conf.py 的私有配置文件,在此文件中写入工程的编译参数选项。下面是个完整的例子:

import os 
import ycm_core 
flags = [ 
    '-std=c++11', 
    '-O0', 
    '-Werror', 
    '-Weverything', 
    '-Wno-documentation', 
    '-Wno-deprecated-declarations', 
    '-Wno-disabled-macro-expansion', 
    '-Wno-float-equal', 
    '-Wno-c++98-compat', 
    '-Wno-c++98-compat-pedantic', 
    '-Wno-global-constructors', 
    '-Wno-exit-time-destructors', 
    '-Wno-missing-prototypes', 
    '-Wno-padded', 
    '-Wno-old-style-cast',
    '-Wno-weak-vtables',
    '-x', 
    'c++', 
    '-I', 
    '.', 
    '-isystem', 
    '/usr/include/', 
] 
compilation_database_folder = '' 
if compilation_database_folder: 
  database = ycm_core.CompilationDatabase( compilation_database_folder ) 
else: 
  database = None 
SOURCE_EXTENSIONS = [ '.cpp', '.cxx', '.cc', '.c', '.m', '.mm' ] 
def DirectoryOfThisScript(): 
  return os.path.dirname( os.path.abspath( __file__ ) ) 
def MakeRelativePathsInFlagsAbsolute( flags, working_directory ): 
  if not working_directory: 
    return list( flags ) 
  new_flags = [] 
  make_next_absolute = False 
  path_flags = [ '-isystem', '-I', '-iquote', '--sysroot=' ] 
  for flag in flags: 
    new_flag = flag 
    if make_next_absolute: 
      make_next_absolute = False 
      if not flag.startswith( '/' ): 
        new_flag = os.path.join( working_directory, flag ) 
    for path_flag in path_flags: 
      if flag == path_flag: 
        make_next_absolute = True 
        break 
     if flag.startswith( path_flag ): 
        path = flag[ len( path_flag ): ] 
        new_flag = path_flag + os.path.join( working_directory, path ) 
        break 
   if new_flag: 
      new_flags.append( new_flag ) 
  return new_flags 
def IsHeaderFile( filename ): 
  extension = os.path.splitext( filename )[ 1 ] 
  return extension in [ '.h', '.hxx', '.hpp', '.hh' ] 
def GetCompilationInfoForFile( filename ): 
  if IsHeaderFile( filename ): 
    basename = os.path.splitext( filename )[ 0 ] 
    for extension in SOURCE_EXTENSIONS: 
      replacement_file = basename + extension 
      if os.path.exists( replacement_file ): 
        compilation_info = database.GetCompilationInfoForFile( replacement_file ) 
        if compilation_info.compiler_flags_: 
          return compilation_info 
    return None 
  return database.GetCompilationInfoForFile( filename ) 
def FlagsForFile( filename, \*\*kwargs ): 
  if database: 
    compilation_info = GetCompilationInfoForFile( filename ) 
    if not compilation_info: 
      return None 
   final_flags = MakeRelativePathsInFlagsAbsolute( 
      compilation_info.compiler_flags_, 
      compilation_info.compiler_working_dir_ ) 
  else: 
    relative_to = DirectoryOfThisScript() 
    final_flags = MakeRelativePathsInFlagsAbsolute( flags, relative_to ) 
 return { 
    'flags': final_flags, 
    'do_cache': True 
  }

基本上,根据你工程情况只需调整 .ycm_extra_conf.py 的 flags 部分,前面说过, flags 用于 YCM 调用 libclang 时指定的参数,通常应与构建脚本保持一致(如,CMakeLists.txt)。flags 会产生两方面影响,一是影响 YCM 的补全内容、一是影响代码静态分析插件 syntastic 的显示结果(详见后文“静态分析器集成”)。另外,你得注意,该配置文件其实就是个 python 脚本,python 把缩进视为语法,如果你是直接拷贝文中的 .ycm_extra_conf.py 小心缩进部分。

设置二,在 .vimrc 中增加如下配置信息:

" YCM 补全菜单配色
" 菜单
highlight Pmenu ctermfg=2 ctermbg=3 guifg=#005f87 guibg=#EEE8D5
" 选中项
highlight PmenuSel ctermfg=2 ctermbg=3 guifg=#AFD700 guibg=#106900
" 补全功能在注释中同样有效
let g:ycm_complete_in_comments=1
" 允许 vim 加载 .ycm_extra_conf.py 文件,不再提示
let g:ycm_confirm_extra_conf=0
" 开启 YCM 标签补全引擎
let g:ycm_collect_identifiers_from_tags_files=1
" 引入 C++ 标准库tags
set tags+=/data/misc/software/misc./vim/stdcpp.tags
" YCM 集成 OmniCppComplete 补全引擎,设置其快捷键
inoremap <leader>; <C-x><C-o>
" 补全内容不以分割子窗口形式出现,只显示补全列表
set completeopt-=preview
" 从第一个键入字符就开始罗列匹配项
let g:ycm_min_num_of_chars_for_completion=1
" 禁止缓存匹配项,每次都重新生成匹配项
let g:ycm_cache_omnifunc=0
" 语法关键字补全		
let g:ycm_seed_identifiers_with_syntax=1

其中大部分内容从注释就能了解,粗体配置项见下文。

YCM 集成了多种补全引擎:语义补全引擎、标签补全引擎、OmniCppComplete 补全引擎、其他补全引擎。

YCM 的语义补全。YCM 不会在每次键入事件上触发语义补全,YCM 作者认为这会影响补全效率而且没什么必要(我持保留意见),YCM 只在如下两种场景下触发语义补全:一是补全标识符所在文件必须在 buffer 中(即,文件已打开);一是在对象后键入 .、指针后键入 ->、名字空间后键入 ::。如下图所示:

YCM 的语义补全YCM 的语义补全

上图中,我先尝试补全类 MyClass 失败,接着我把 MyClass 所在的文件 MyClass.h 打开后切回 main.cpp 再次补全类 MyClass 成功,然后在对象 mc 后键入 . 进行成员补全;YCM 的标签补全。语义补全的确强大,但受限挺多,如果我要补全 STL 中的泛型算法 count_if() 岂不是还要先打开库头文件 algorithm?不用,YCM 也支持标签补全。要使用标签补全,你需要做两件事:一是让 YCM 启用标签补全引擎、二是引入 tag 文件,具体设置如下:

" 开启 YCM 标签引擎
let g:ycm_collect_identifiers_from_tags_files=1
" 引入 C++ 标准库tags
set tags+=/data/misc/software/misc./vim/stdcpp.tags

其中,工程自身代码的标签可借助 indexer 插件自动生成自动引入,但由于 YCM 要求 tag 文件中必须含有 language: 字段(ctags 的命令行参数 --fields 必须含有 l 选项),所有必须通过 indexer_ctagsCommandLineOptions 告知 indexer 调用 ctags 时注意生成该字段,具体设置参见“代码导航”章节;前面章节介绍过如何生成、引入 C++ 标准库的 tag 文件,设置成正确路径即可。另外,由于引入过多 tag 文件会导致 VIM 变得非常缓慢,我的经验是,只引入工程自身(indexer 自动引入)和 C++ 标准库的标签(上面配置的最后一行)。如下图所示:

YCM 的标签补全YCM 的标签补全

YCM 的 OmniCppComplete 补全引擎。我要进行 Linux 系统开发,打开系统函数头文件觉得麻烦(也就无法使用 YCM 的语义补全),引入系统函数 tag 文件又影响 VIM 速度(也就无法使用 YCM 的标签补全),这种情况又如何让 YCM 补全呢?WOW,别担心,YCM 还有 OmniCppComplete 补全引擎,只要你在当前代码文件中 #include 了该标识符所在头文件即可。通过 OmniCppComplete 补全无法使用 YCM 的随键而全的特性,你需要手工告知 YCM 需要补全,OmniCppComplete 的默认补全快捷键为 ,不太方便,我重新设定为 ;,如前面配置所示:

inoremap <leader>; <C-x><C-o>

比如,我要补全 fork(),该函数所在头文件为 unistd.h,正确添加 #include 后即可补全。如下图所示:

YCM 的 OmniCppComplete 补全引擎YCM 的 OmniCppComplete 补全引擎

其实,只要你正确插入头文件,YCM 的 OmniCppComplete 补全引擎可以替代语义引擎和标签引擎,比如,上面的 MyClass 在不打开 MyClass.h 情况下也可由OmniCppComplete(键入 ;)补全:

OmniCppComplete 替代语义、标签补全OmniCppComplete 替代语义、标签补全

YCM 的其他补全。YCM 还集成了其他辅助补全引擎,可以补全路径、头文件、甚至可以在注释中继续补全。如下图:

YCM 的其他补全YCM 的其他补全

从我的经验来看,要想获得最好的补全体验,你应综合使用 YCM 的各种补全引擎!

另外,YCM 不愧是 Google 工程师开发的,它的匹配项搜索方式非常智能,你无须从前往后逐一输入,YCM 会对你输入的内容进行模糊搜索,如下:

YCM 模糊搜索YCM 模糊搜索

此外,YCM 对大小写也非常智能,当你输入全小写时 YCM 对大小写不敏感,当然输入中有大写字母时 YCM 对大小写敏感:

YCM 大小写智能敏感YCM 大小写智能敏感

上图中,当我键入 tia 时这两个对象均匹配,接着输入大写 L 时就只剩 This_Is_A_Long_Name 匹配。