探索Vala语言

Vala语言学习心得

Posted by wszqkzqk on October 17, 2022
本文字数:25000

未经特殊说明,本文中的所有代码均采用LGPL v2.1+协议公开

简介

Vala语言是一门专门为GObject对象设计的编程语言,语法类似于C#。Vala并没有自己的运行时,而是在编译时由Vala编译器将Vala源代码转化为C源代码,仅仅依赖C语言的基本特性,实现了现代语言的类型推断、lambdaclass等各种高级功能。通常来说,基础的Vala程序的依赖仅有GLib和系统的C语言库,十分小巧精炼。Vala还具有自动的内存管理功能,它在编译时进行引用计数,生成的C代码与手动管理内存的C代码结构上类似,避免了引入GC或进行运行时引用计数检查对程序效率的额外开销。因此,Vala同时拥有C#的高开发效率与C语言的高运行效率和低内存占用。

此外,Vala语言还具有跨平台的特性。Vala背靠完备的基础库GLib,强大的图形库GTK,高度集成的媒体库GStreamer,以及SDL2、ZLib、OpenGL等各种各样的非GNOME库语言绑定,还可以通过vapi进一步拓展。Vala既能用来开发高效率运行的CLI应用,又能用来开发功能丰富的GUI应用,适用场景较广。

附上与Vala相关的重要网站:

优势

语法

Vala语法与C#非常类似,由于Vala语言相对而言不那么大众,很多时候学习Vala语法可以直接参考C#的资料。

笔者在这里列出了一些Vala代码语法的特别之处,简要示出Vala语言的优势。

变量类型命名

Vala与Rust几乎诞生于同一时期,两者都不约而同地使用了较为简洁的整数类型名称。Vala中charintlong等拼写简单的类型名仍然不变,但是对两个单词及以上的类型名进行了调整,仅简单指出其符号与位数来表示,如:没有usigned int,而是使用uint;没有long long,而是使用int64;没有usigned long long,而是使用uint64。这使得代码更加简洁,既方便编写,又方便阅读。

此外,Vala给各种长度的整数都给出了跨平台的保证位数的实现,例如:long在64位Linux下为64位,但在64位Windows下为32位,这时如果有必要可以考虑使用保证长度的int64int32实现。

匿名函数lambda

Vala的lambda匿名函数语法特别简洁,基本结构为params => body,具体形式如下:

([arg1], [arg2], [...]) => statement
([arg1], [arg2], [...]) => {block}

前面的小括号内传递参数,不需要声明类型。后面的语句直接给出函数内容,同样不需要声明返回值的类型。

Vala的lambda匿名函数使用便捷,功能也比Python中仅相当于return语句的lambda函数更强大。在很多传参时需要给出一个函数的地方(例如信号的connect)使用特别方便。

main函数的灵活性

C#要求将Main函数置于一个class内,而Vala的main函数既可以在class内,又可以在class外,必要时将Vala当作是简化的C语言来用也未尝不可。

指针适度使用

由于Vala语言具有自动内存管理功能,一般不需要手动管理指针,例如,与C++对比,Vala的foreach循环不需要手动包含取地址的操作:

void main () {
    int[] nums = {1, 2, 3, 4, 5};
    foreach (var i in nums) {
        print ("%d\n", i);
    }
    // Output:
    // 1
    // 2
    // 3
    // 4
    // 5
}

Vala的中的List等容器也不需要手动指针清理,在离开其有效语段或手动将其赋值为空后即可自动释放(list = new List (),或者list = null,但不推荐后者,对于某些数据结构后者无法初始化,并不适用)。

同时,Vala又与C#不同,Vala仍可以手动在任何地方使用指针,不需要特别包括在unsafe{}语段中,例如:

uint int64_hash (double? v) {
    return (uint) (((*(uint64*) v >> 32)) ^ (*(uint64*) v & 0xffffffff));
}

这里定义了一个双精度浮点数的hash函数,为了避免将浮点直接转化为整数时导致的溢出或整数部分相同时的剧烈hash碰撞等种种问题,此处将double的前32位与后32位取异或,得到hash值。这一过程需要在存储double的内存空间中取出两个整数,不可避免地需要使用指针。

但即使如此,由于需要指针的常见数据结构在Vala的标准库GLib以及容器库Gee中已有较为完备的实现,Vala中极少需要手动使用指针,Vala官方也建议谨慎手动使用。

语法糖设计

Vala中还有很多的语法糖,可以简化语句并提高开发效率。例如:

void main () {
    var map = new HashTable<string, int> (str_hash, str_equal);

    map.set ("one", 1);
    map["two"] = 2;             // The same as map.set ("two", 2)

    var a = map.get ("one");
    var b = map["two"];        // The same as map.get ("two")

    if (map.contains ("one")) {
        print ("\"one\" is in map");
    }

    if ("two" in map) {         // The same as map.contains ("two")
        print ("\"two\" is in map");
    }
}

入门曲线

笔者认为Vala语言的学习曲线明显较C++平缓。一方面,Vala的语法较C++简单,易于上手;另一方面,Vala具有自动化的内存管理功能,不用手动管理内存,甚至由于GLib与Gee内置的数据结构极其丰富,也基本上可以不用指针。Vala还不需要C++那样的include语句,对于基础的编程内置的GLib库完全足够,不需要知道调用其他库的命令,这对入门也十分友好;而想要在Vala中使用其他库时也十分简单,不需要使用预处理语句或者复杂的链接命令,只需要在编译参数或者Unix Shebang中用--pkg=xxx-x.x指明所需要的库即可。此外,由于Vala有强大的标准库,很多在C++中需要自己慢慢实现的函数或功能在GLib库中已有集成,同样也降低了初学者的学习负担。

事实上,笔者虽然自初中以来一直都有学习Vala的想法,但一直没有真正去尝试,直到在学了Python以后自学C++受阻(只会Python的菜鸡刚刚接触这样较底层的语言,再加上是较混乱的自学,没有头绪,当时确实感觉有点困难🤣🤣🤣),才重新想起Vala语言,这才开始学习Vala。这也许也可以作为Vala学习曲线较C++平缓的一个论据😂。

标准库与功能

Vala的设计即为现代化的高级语言,拥有大量现代语言的功能。此外,Linux平台下使用最广泛、功能集成最丰富的C语言库——GLib可以认为是Vala语言的标准库。GLib内置了极其丰富的内置函数、GObject对象、I/O通道、数据结构,因此,Vala语言内置的标准库也十分强大。下面笔者将对一些自己喜欢的Vala内置的强大功能进行简要介绍。

字符串处理及输入输出

笔者非常喜欢Vala强大的字符串处理功能。

Vala支持直接用+拼接字符串:

void main () {
    var s1 = "123";
    var s2 = "456";
    var s3 = s1 + s2 + "\n";
    print (s3);
    // Output:
    // 123456
}

对于一般的字符串处理,除了C语言风格的xxx.printf ()处理外,Vala还支持@"content"形式的模板字符串,在模板字符串中,可以用$xxx$(xxx)插入所需要输出的内容,可以是变量,也可以是一个运算语句,例如:

void main () {
    int n = 123456;
    uint m = 987654321;
    double pi = 3.141592653589;
    print (@"$m + $n + pi = $(m + n + pi)\n");
    // Output:
    // 987654321 + 123456 + pi = 987777780.14159262
}

使用模板字符串进行输出,既不用像C语言风格的printf一样在字符串中用%声明变量类型,又不用像C++的cout一样用很长一串<<连接。Vala的模板字符串是先完整生成,再整体输出,也避免了cout的线程不安全问题,是一种方便又不乏灵活的方法。另外,模板字符串可以在生成后存储待用而不输出,整体来看是一种与Python语言的f-string十分相似的方法,便于使用(也许现在C++的std::printstd::format也差不多了🥲)。

当然,Vala也支持C语言风格的printf格式化,效果与之前的模板字符串实现类似,但是更加麻烦:

void main () {
    int n = 123456;
    uint m = 987654321;
    double pi = 3.141592653589;
    print ("%u + %d + pi = %f\n", m, n, pi + m + n);
    // Output:
    // 987654321 + 123456 + pi = 987777780.141593
}

Vala的模板字符串中$的使用类似于Unix Shell的变量引用,符合众多Linux用户的使用习惯,同时功能强大,很多时候是简化输出代码的一大利器。

除此之外,Vala还支持字符串切片、字符串中字符的提取,例如:

void main () {
    var s1 = "abcdefg";
    var s2 = s1[0:3];
    var c = s1[3];
    print (@"$s2\n$c\n");
    // Output:
    // abc
    // d
} 

另外需要注意的是,字符串切片可以使用负数索引,-n的意义为倒数第-n+1个字符的位置;但从字符串中提取字节时负数索引的含义为内存地址相对与字符串第一个字节的关系,直接用形如s[-n]获取字节将会越界,产生未定义行为(可能是段错误,也可能是返回一个奇怪的值),故一般情况下从字符串中提取字节时不应使用负数索引(在字符串由char[]或者char *通过特殊方式转化而来的时候字符串第一个字节前可能有明确内容,负数索引在这个时候有意义)。

void main () {
    var s1 = "abcdefg";
    var s2 = s1[1:-1]; // "bcdef"
    var c1 = s1[-1]; // WRONG! Access out of bounds!
}

Vala还支持逐字字符串,类似于Python的r-string或C#的@""(然而C#的逐字字符串的语法在Vala中表示字符串模板),形如"""xxx"""(注意:Python中也有类似语法表达,但是意义为多行字符串;Vala语言与Python不同,不依赖换行符作为语句分隔,因此各种表达方式的字符串都能跨行,这里的"""xxx"""还特别表示了逐字字符串)

Vala的文字输入也较为方便。Vala除了可以使用C语言风格的getcscanf等外,还有read_line函数可以读取一整行的内容,注意使用时是通过FileStream对象下的方法来进行的(getcscanf函数也是如此),如果要从标准输入流读取,即使用stdin.read_line()

void main () {
    var s = stdin.read_line ();
    print (@"Your input is: $s\n");
    // Output:
    // Your output is: xxx(your input)
}

这一函数功能上类似于Python等语言的input,对熟悉Python的人来说比较方便。

由于Vala背靠强大的GLib库,对字符串的高级处理也有很好的支持。诸如splitreplacehas_prefixhas_suffixascii_downascii_upstrip等较高级的处理函数在内置的string对象中均有实现,可以方便地实现字符串的高级处理。以下是示例:

void main () {
    var s1 = "Hello, beautiful but dangerous world!";
    var tab = s1.split (" ");
    print ("%s\n%s\n", tab[0], tab[4]);
    // Output:
    // Hello,
    // World!

    var s2 = s1.ascii_up ();
    print ("%s\n", s2);
    // Output:
    // HELLO, BEAUTIFUL BUT DANGEROUS WORLD!

    var s3 = s1.replace ("beautiful but dangerous", "wonderful");
    print ("%s\n", s3);
    // Output:
    // Hello, wonderful world!

    var s4 = s1.reverse ();
    print ("%s\n", s4);
    // Output:
    // !dlrow suoregnad tub lufituaeb ,olleH

    var s5 = "  \t  Hello, world!  \t\t ";
    var s6 = s5.strip ();
    print ("%s\n", s6);
    // Output:  
    // Hello, world!

    var pre = "Hell";
    if (s6.has_prefix (pre)) {
        print (@"\"$pre\" is the prefix of \"$s6\"\n");
    }
    // Output:  
    // "Hell" is the prefix of "Hello, world!"
}

Python程序员需要注意,Vala中的split必须要手动指定分隔符,当分隔符指定为通常的空格时,行为将与Python中默认的无参split()不同,指定了split(" ")时,无论是在Python中还是在Vala中,多个连续空格间将会分隔出空字符串,而无参的split()则没有:

tab = "1    2".split()

这样得到的tab内容为["1", "2"]。而对于Vala:

var tab = "1    2".split (" ");

这样得到的tab内容为{"1", "", "", "", "2"}。有时忘记处理空字符串会引发某些bug。

正则表达式

Vala内置集成了强大的正则表达式功能,可以将一般的字符串形式的正则表达式编译为正则表达式对象,也可以在代码中直接使用正则表达式。Vala语言在编译时自动识别正则表达式内容,正则表达式在Vala中与语言的集成度高,使用方便:

void main () {
    var msys2_dep_regex = msys2_dep_regex = /.*(\/|\\)(usr|ucrt64|clang64|mingw64|mingw32|clang32|clangarm64)(\/|\\)/;
    var path = "D:\\msys64\\ucrt64\\bin\\libglib-2.0-0.dll";
    MatchInfo match_info;

    if (msys2_dep_regex.match (path, 0, out match_info)) {
        print (@"This file is in msys2 environment \"$(match_info.fetch(0))\".\n");
    } else {
        print ("This file doesn't in msys2 environment.\n");
    }
    // Output:
    // This file is in msys2 environment "D:\msys64\ucrt64\".
}

直接的正则表达式对象不仅简化了语法,而且Vala会在编译时的时候检查正则表达式的正确性(LSP也会在IDE中指出错误),便于及早发现正则表达式中的错误,避免人为的疏忽进入程序中,增加了程序的稳健性。

当然,Vala也支持从字符串编译生成正则表达式。这一方式虽然比直接的正则表达式对象更麻烦,但由于源字符串可以按照一定的规则生成,在某些情况下灵活性可能更强:

void main () {
    var env = "ucrt64";
    var msys2_dep_regex = new Regex (@".*(/|\\\\)$env(/|\\\\)");
    var path = "D:\\msys64\\ucrt64\\bin\\libglib-2.0-0.dll";
    MatchInfo match_info;

    if (msys2_dep_regex.match (path, 0, out match_info)) {
        print (@"This file is in msys2 environment \"$(match_info.fetch(0))\".\n");
    } else {
        print ("This file doesn't in msys2 environment.\n");
    }
    // Output:
    // This file is in msys2 environment "D:\msys64\ucrt64\".
}

进程支持

得益于GLib中集成的强大功能,Vala创建进程十分方便,Process命名空间下的函数可以为进程创建提供很好的支持。进程创建可以用封装好的、跨平台的spawn_asyncspawn_syncspawn_command_line_async spawn_command_line_sync 等函数实现。在这里列出一个稍综合的例子:

#!/usr/bin/env -S vala -X -O2 -X -march=native -X -pipe

void main () {
    Intl.setlocale ();

    string cc_out, cc_err;
    try {
        Process.spawn_command_line_sync ("cc -v", out cc_out, out cc_err);

        var re = /(gcc|clang) \S+ (([0-9]+)\.([0-9]+)\.[0-9]+)/;
        MatchInfo match_info;
        if (re.match (cc_err, 0, out match_info)) {
            var cc = match_info.fetch (1);
            var version = match_info.fetch (2);
            print (@"You are using $cc!\n"
            + @"The version of your $cc is: $version\n");

            var main_ver = uint.parse (match_info.fetch (3));
            var sub_ver = uint.parse (match_info.fetch (4));
            uint main_req, sub_req;
            if (cc == "gcc") {
                main_req = 4;
                sub_req = 6;
            } else {
                main_req = 3;
                sub_req = 1;
            }
            if ((main_ver > main_req) || (main_ver == main_req && sub_ver >= sub_req)) {
                print ("Your compiler is C11 compatible!\n");
            } else {
                print ("Your compiler is NOT C11 compatible!\n");
            }
        } else {
            print ("There is neither gcc nor clang!\n");
        }
    } catch {
        print ("There is neither gcc nor clang!\n");
    }
}

这个例子是一个简单的编译器版本检查器,程序运行cc命令,并利用正则表达式判定环境中的默认编译器是gcc还是clang。对于syncspawn函数,还可以将进程的输出指定到特定的字符串中,退出状态也可以指定输出到一个整型变量中。这里需要注意的是,一般来说程序的输出信息会输出到standard_output,报错信息输出到standard_error;但在这里编译器会将版本信息输出到standard_error里面,若匹配上述代码中的cc_out将不会匹配到任何结果。程序在识别了编译器类型后,会对编译器的版本进行检查,并根据编译器是否兼容C11标准输出不同的内容。

容器

Vala中一般可以选择使用两个容器库,一个是Vala程序本身即依赖的GLib,一个是专门为Vala设计的容器库Gee。两者各有一些优缺点:GLib库应用范围比Vala大,库内容本身得到的测试更加充分,更加可靠,但与Vala的集成相对不好,在集成与调用上容易出现问题;而Gee库本身即是由Vala所写,与Vala的集成更好,某些特性上也更加强大,例如Gee中的动态数组默认会进行越界检查,但GLib中的动态数组没有这样的设计,Gee的缺点是本身得到的验证与测试相对更少,可靠性相对更低,并且会为程序引入额外的依赖。在应用中具体使用哪个库中的容器可以根据实际情况按需选择。

这里列出部分GLib中的容器与Gee中的容器的对应关系(空缺不一定是没有,也可能是待补充),还列出了C++中的对应类型作为对比与参考:

容器类型 GLib实现 Gee实现 C++标准库实现(对比) 备注
列表(动态数组) GLib.Array Gee.ArrayList vector  
双端链表 GLib.List Gee.LinkedList list  
单向链表 GLib.SList Gee.ConcurrentList slist  
Hash集合 GLib.GenericSet Gee.HashSet unordered_set GLib实现必须自己指定hash函数和相等判断函数;Gee实现中默认string类型是比较内容,其余类型均是比较内存地址,比较内容需要手动指定函数
平衡二叉树集合   Gee.TreeSet set Gee实现中默认string类型是比较内容,其余类型均是比较内存地址,比较内容需要手动指定函数
Hash字典 GLib.HashTable Gee.HashMap unordered_map GLib实现必须自己指定hash函数和相等判断函数;Gee实现中默认string类型是比较内容,其余类型均是比较内存地址,比较内容需要手动指定函数
平衡二叉树字典 GLib.Tree Gee.TreeMap map GLib实现必须自己指定比较函数;Gee实现中默认string类型是比较内容,其余类型均是比较内存地址,比较内容需要手动指定函数
双端队列 GLib.Queue Gee.ArrayQueue deque  

注意在Vala中使用GLib的容器对象时,泛型应当使用nullable的类型。

其他

除此之外,GLib库还有强大的线程支持、文件系统相关支持、事件循环支持,这些功能在Vala中都有堪称完美的集成,此处不再一一赘述。

Vala Scripts与Unix Shebang

Vala语言支持将源代码第一行写为Unix Shebang的形式:

#!/usr/bin/env vala

赋予该源代码文件可执行权限后即可直接执行该未经编译的源代码。在执行时,系统先会自动调用vala命令(不是一般编译时用的valac命令)对代码文件进行AOT编译,然后再自动执行编译得到的文件,整体上起到了类似脚本的效果。因此简单Vala的程序的调试十分方便,免去了手动编译的麻烦。这一特性也使得Vala可以当作脚本语言(即Vala Scipts)在类Unix系统下使用。由于在Linux下valac与gcc编译耗时都较短,Vala Script启动延迟低,而且运行的是编译好的本机二进制机器码,没有JIT或解释器的性能开销,运行效率高,运行效率高,有着不错的体验。

另外,我们也可以在Shebang中向编译器正常传入参数,例如,可以开启编译器优化:

#!/usr/bin/env -S vala -X -O2 -X -march=native -X -pipe

这里需要注意的是env的特性:需要传递-S参数表示后面的命令按照空格分隔。传给Vala编译器的命令中,-X后所紧跟的内容会直接传递给gcc编译器,这里的-O2表示按照O2等级启用编译器优化,-march=native表示合理、充分利用本机支持的所有指令集,-pipe表示让gcc生成的中间文件直接通过管道传递,不写入硬盘。这三个选项均是用来加快运行速度或者编译速度的。

由于Vala Scripts运行的时候存在自动编译的过程,如果安装了某些能够加速编译的软件,可以加入调用的命令参数中,可能会有一定程度上减小程序启动延迟的效果:

#!/usr/bin/env -S vala -X -O2 -X -march=native --cc="ccache gcc" -X -fuse-ld=mold -X -pipe

以上的Shebang参数调用了ccache进行编译缓存加速,并使用了先进、高效的mold链接器进行链接,可以加快编译速率。当然,这需要提前安装好所需要的moldccache才能正常运行。

我们还可以在Shebang中为Vala编译器指定所需要调用的库,例如:

#!/usr/bin/env -S vala --pkg=gio-2.0 --pkg=gtk+-3.0

这代表令Vala编译器寻找并调用giogtk+-3.0这2个库。同时,这样的语句也可以被LSP识别,令LSP能够理解所调用的库中包含的内容。

注意:这一功能虽然强大,但毕竟与Unix特性高度相关,仅在类Unix平台下可用,Windows下无法直接执行带有Shebang的文件,仅可用于LSP识别

当然,作为”Scripts”的类似物,Vala还实验性支持顶级语句功能,即,像脚本语言一样生成该程序,不用将程序入口点放在main ()函数中:

#!/usr/bin/env -S vala -X -O2 -X -march=native -X -pipe

var a = 123;
var b = 456;
print (@"$a plus $b is $(a+b)");

以上Vala代码虽然没有main函数,但是也可以正常编译运行,或者在Unix下授予可执行权限执行直接执行。程序会将顶级语段视作程序入口点正常处理。

这些特性使得Vala虽然实际上是一个静态编译的语言,但也有着一些贴近脚本语言的方便特性。同时,高度简化的语法也降低了新手入门的门槛。

IDE与LSP支持

目前,GNOME Builder与安装了Vala语言插件的VS Code都是非常易用的Vala开发环境,对于较轻量级的开发,KDE的高级文本编辑器Kate也是一个不错的选择。结合Vala language server或gvls的LSP功能,这些IDE或编辑器支持了语法高亮、自动补全、代码格式化、代码静态分析、定义跳转、文档集成、调试断点等强大功能,增加了开发效率。

不足

自Vala语言于2006年推出到现在,始终没有能够发布正式版。该语言虽然开发高效,使用便捷,但是使用人数与开发人数都远远小于主流大众语言。因此,尤其是在一些不太常用的地方,Vala可能会存在一定的编译器Bug。而这些编译器Bug在开发过程中往往难以定位,降低开发效率。Vala语言主要是为了GObject开发,而GObject相关程序在Linux下有着很广泛的使用,但是在Windows平台下用得并不多,所以Vala对Windows平台下的开发体验可能顾及较少。

笔者在这里对自己在学习Vala过程中遇到的编译器Bug或反人类之处、不合理之处予以列举。

反斜杠灾难:Windows下的编译器Bug(已修复)

Vala程序支持使用GDB等工具进行调试(需要在编译时向valac编译器传递-g参数)。Vala编译器依靠在生成的C源代码中插入#line语句来加入程序中对应的源代码文件的路径以及行数等调试信息。

然而,在笔者将这一Bug修复前,Vala会将编译参数中的文件路径未加处理即写入到生成的C源代码中。对于Linux等类Unix平台,文件路径的分隔符为/,一般不会出现问题。但是Windows平台的文件路径分隔符为\,C源代码字符串中的\将会被识别为转义字符,这会导致#line语句中的源代码文件路径错误,使得调试无法进行;如果在路径中\后面存在不可转义的字符,还会在编译的时候大量报错。

笔者已向上游提交补丁,该Bug于0.56.3修复

头文件包含问题:GType的使用与GLib的包含(已修复)

Vala通常需要依赖GLib与GObject进行使用。默认情况下,任何Vala源代码都看作默认加入了一行using GLib,默认使用的基本数据类型(例如:int, uint, int64, uint64, char, double)都是GLib中定义的(对应上文的例子,即gint, guint, gint64, guint64, gchar, gdouble),默认包含的大量内置函数(例如print, get_real_time)也来自GLib(上文例子对应的是g_print, g_get_real_time)。甚至,Vala中的Bool值也来自于GLib中宏的定义。

另外,Vala语言本身并没有#include语句,Vala在C代码层面对头文件的使用由Vala编译器自动处理。这导致了有时即使没有显式使用GLib中的内容,也存在对GLib头文件的依赖,如果Vala编译器没有在生成的C代码中自动包含所需要的头文件,便会造成编译错误。

例如:

void main () {
    if (true) {
        stdout.printf ("test");
    }
}

将这段代码编译,或授予Unix可执行权限执行,会提示以下错误信息:

/tmp/temptest.vala.XXDIT1.c: In function '_vala_main':
/tmp/temptest.vala.XXDIT1.c:11:13: error: 'TRUE' undeclared (first use in this function)
   11 |         if (TRUE) {
      |             ^~~~
/tmp/temptest.vala.XXDIT1.c:11:13: note: each undeclared identifier is reported only once for each function it appears in
error: cc exited with status 256
Compilation failed: 1 error(s), 0 warning(s)

还有更加隐式的例子:

void main () {
    while (1 == 1) {
        stdout.printf ("test\n");
        break;
    }
}

同样会出现编译错误:

/tmp/1.vala.c: In function '_vala_main':
/tmp/1.vala.c:11:16: error: 'TRUE' undeclared (first use in this function)
   11 |         while (TRUE) {
      |                ^~~~
/tmp/1.vala.c:11:16: note: each undeclared identifier is reported only once for each function it appears in
error: cc exited with status 1
Compilation failed: 1 error(s), 0 warning(s)

这是因为Vala生成代码中的TRUE是在GLib的头文件中定义的,而这段程序没有明显地使用GLib的内容,Vala编译器并没有自动包含GLib的头文件,使得TRUE未定义,造成编译失败。

笔者向上游反馈了这一bug,该bug已于目前的git存储库中修复

容器中对象的运算符问题:a[i]+=1a[i]=a[i]+1a[i]++的坑(已部分修复)

这样一段简单的代码即可试出这一问题:

void main() {
    Intl.setlocale ();
    var dic = new HashTable<string, int64> (str_hash, str_equal);
    dic["abc"] = 100;
    dic["abc"] += 5;
    print("%lld\n", dic["abc"]);
}

以上代码可以正常编译运行。但注意到语句dic[k] = dic[k] + 1,若将其改为dic[k]++,将会在编译时报错:

error: The expression `Gee.HashTable<string, int64>' does not denote an array

而改成dic[k] += 5后,该语句的实际效果变成了dic[k] = 5,在编译时传递-C参数,检查Vala编译器生成的C代码,对比正常结果与异常结果可以发现,改成dic[k] += 5后,Vala显然没有正确处理+=语句:

--- /tmp/collectionBug.c	2022-12-23 20:51:03.542411347 +0800
+++ /home/wszqkzqk/projects/vala-gtk-study/collectionBug.c	2022-12-23 20:51:32.859478197 +0800
@@ -27,7 +27,6 @@
 	gchar* _tmp3_;
 	gchar* _tmp4_;
 	gconstpointer _tmp5_;
-	gconstpointer _tmp6_;
 	setlocale (LC_ALL, "");
 	_tmp0_ = g_str_hash;
 	_tmp1_ = g_str_equal;
@@ -36,10 +35,9 @@
 	_tmp3_ = g_strdup ("abc");
 	g_hash_table_insert (dic, _tmp3_, (gint64) 100);
 	_tmp4_ = g_strdup ("abc");
+	g_hash_table_insert (dic, _tmp4_, (gint64) 5);
 	_tmp5_ = g_hash_table_lookup (dic, "abc");
-	g_hash_table_insert (dic, _tmp4_, _tmp5_ + 5);
-	_tmp6_ = g_hash_table_lookup (dic, "abc");
-	g_print ("%lld\n", _tmp6_);
+	g_print ("%lld\n", _tmp5_);
 	_g_hash_table_unref0 (dic);
 }

在使用通常的非容器中的变量时,Vala编译器均能正常处理+=++操作。很明显,在这个例子中,Vala编译器处理容器中的内容时出现了Bug。

在此处map[i] += kmap[i] = map[i] + k表现不同的问题已于最新版存储库中修复。有些可笑的是,这一Bug在12年前就有所报道,在10年前就有了修复补丁,但直到最近才得到合并。这可能是因为当时Vala的主要维护者还是Vala的原作者Jürg Billeter,不是现在的Rico Tzschichholz,可能是维护的交接工作中有些疏忽。

然而,目前为止,容器中对象不能使用a[i]++或者++a[i]表达式的问题依然没有得到修复。

类型检查

Vala是强类型语言,在一定程度上保证类型安全的同时,很多本来完全可以隐式转化甚至直接等价的类型并不能隐式转化,因此也带来了一些不便。例如,Vala中针对GLib中数据结构的GLib.HashFunc与针对Gee中数据结构的Gee.HashDataFunc在C原因呢代码层面上来看本质上都是uint类型,然而在Vala程序中,要求传递一个Gee.HashDataFunc函数作为参数的地方并不能直接使用一个类型为HashFunc的函数,甚至也不能使用类型为uint的函数,如果要使用,需要在传参时显式强制类型转化,这样的规定并没有意义,还会为编程带来不便。

此外,Vala对部分nullable类型的适配也并不充分,内置的大量能够为intuintdouble等自动获取内置hash、equal、cmp函数数据结构下的方法在遇到int?uint?double?时通通失灵,因此对于nullable类型,这些函数还往往需要手动编写或指定,比较麻烦。笔者对该问题已经向Libgee提交了修复补丁,但是目前维护者似乎并没有合并的意向。

Vala在一些该进行类型检查的却又没有很好地进行类型检查。例如:

#!/usr/bin/env -S vala -X -O2 -X -march=native -X -pipe
void main () {
    var dic = new HashTable<string, double> (str_hash, str_equal);
    dic["pi"] = 3.141592653589793;
    dic["e"] = 2.718281828459045;
    dic.foreach ((key, value) => {
        print (@"$key --> $value\n");
    });
}

这里的泛型参数double并不受支持,需要使用double?令Vala正确自动处理指针。然而,Vala编译器并没有在此报错,报错发生在了C语言的编译阶段:

D:/17265/test.vala.c: In function '_vala_main':
D:/17265/test.vala.c:126:36: warning: passing argument 2 of 'g_hash_table_foreach' from incompatible pointer type [-Wincompatible-pointer-types]
  126 |         g_hash_table_foreach (dic, ___lambda4__gh_func, NULL);
      |                                    ^~~~~~~~~~~~~~~~~~~
      |                                    |
      |                                    void (*)(const void *, const void *, void *)
In file included from D:/msys64/ucrt64/include/glib-2.0/glib.h:52,
                 from D:/17265/test.vala.c:4:
D:/msys64/ucrt64/include/glib-2.0/glib/ghash.h:109:61: note: expected 'GHFunc' {aka 'void (*)(void *, void *, void *)'} but argument is of type 'void (*)(const void *, const void *, void *)'
  109 |                                             GHFunc          func,
      |

而使用Gee.HashMap时将会在Vala代码向C代码编译的阶段正常报错:

#!/usr/bin/env -S vala --pkg=gee-0.8 -X -O2 -X -march=native -X -pipe
void main () {
    var dic = new Gee.HashMap<string, double> ();
    dic["pi"] = 3.141592653589793;
    dic["e"] = 2.718281828459045;
    foreach (var i in dic) {
        print (@"$(i.key) --> $(i.value)\n");
    }
}
gee-0.8.vapi:195.122-195.122: error: `double' is not a supported generic type argument, use `?' to box value types
  195 |         public abstract class AbstractMap<K,V> : GLib.Object, Gee.Traversable<Gee.Map.Entry<K,V>>, Gee.Iterable<Gee.Map.Entry<K,V>>, Gee.Map<K,V> {
      |                                                                                                                       
          ^
gee-0.8.vapi:195.122-195.122: error: `double' is not a supported generic type argument, use `?' to box value types
  195 |         public abstract class AbstractMap<K,V> : GLib.Object, Gee.Traversable<Gee.Map.Entry<K,V>>, Gee.Iterable<Gee.Map.Entry<K,V>>, Gee.Map<K,V> {
      |                                                                                                                       
          ^
Compilation failed: 2 error(s), 0 warning(s)

因此,在使用GLib中的容器时,传递泛型参数尽量传递nullable(即末尾加了?的)类型。

连续比较的不完善(已修复)

Vala支持简洁的连续比较写法,例如:

bool foo = (1 <= 2 && 2 <= 3);
bool bar = (1 <= 2 <= 3);

Vala支持以上两种写法,第二种写法更加简洁。然而,Vala对连续比较的支持并不完善,存在Bug。对于字符串,Vala并不支持连续的比较,使用连续比较写法时(例如"1" <= "2" <= "3")会像C语言一样将表达式的前半部分("1" <= "2")理解为一个布尔值,再将其与后面的内容("3")进行比较,最终因为布尔值和字符串类型不兼容而报错。此外,对于任何数据类型,Vala的比较都不支持连等,像111 == 111 == 111的写法也会因为布尔值与一般的整数不兼容而报错。

笔者已提交有关这一问题的修复补丁,并与Vala项目现任负责人Rico Tzschichholz一起修复了这个Bug,目前该补丁已经合并,预计会加入到Vala 0.58版本中。

LSP的稳定性(已修复)

Vala的LSP虽然功能强大丰富,支持以vscode插件的形式非常方便地调用,但目前稳定性并不理想,尤其是在Windows平台下崩溃极其频繁,甚至到了难以使用的地步。

Vala Language Server 0.48.7+已经修复了主要的稳定性Bug,现在Vala的LSP功能强大,体验非常好。

Windows下默认时没有充分使用彩色报错

Vala对当前是否处于交互文本界面的检测有一些平台依赖的Bug。Windows平台下,Vala的报错提示在默认情况下通常不是彩色的,需要在编译时加入--color=always参数才能开启彩色报错。

笔者已经提交相关补丁修复此问题,使得Windows下也能默认彩色报错,不再需要自己加--color=always参数,该补丁已经合并,会加入到Vala 0.58中。

宏支持

Vala的预处理支持类似于C#,功能较弱,只能使用#if等条件编译语句,不能使用#define自己定义内容。

其他问题:产生的C代码的效率与可读性

Vala编译器在将Vala源代码编译为C源代码时,会产生很多冗余的_tmpX_变量,以及多余的变量间赋值操作,造成额外的性能开销。

此外目前Vala编译器似乎是为了兼容古老的C89,某些代码生成得十分复杂,例如这样一个简单的循环:

void main () {
    for (var i=0;i<10;i+=1) {
        print ("%d/n", i);
    }
}

由此编译得到的C代码为:

/* tmp.c generated by valac 0.56.3, the Vala compiler
 * generated from tmp.vala, do not modify */

#include <glib.h>

static void _vala_main (void);

static void
_vala_main (void)
{
	{
		gint i = 0;
		i = 0;
		{
			gboolean _tmp0_ = FALSE;
			_tmp0_ = TRUE;
			while (TRUE) {
				if (!_tmp0_) {
					i += 1;
				}
				_tmp0_ = FALSE;
				if (!(i < 10)) {
					break;
				}
				g_print ("%d/n", i);
			}
		}
	}
}

int
main (int argc,
      char ** argv)
{
	_vala_main ();
	return 0;
}

可见,Vala语言将这个在C99及以后的C语言标准中都语法正确的for循环转化成了一个较为复杂的while循环,并且使用的是while (TRUE)if (!condition) {break;}的结构。Vala编译器还将i+=1编译到了一个条件判断内,以便控制在首次循环时不执行i+=1。这些都使得Vala生成的C代码较为冗长,可读性较低。

Vala这样做的原因也可能不是为了兼容C89的语法,而是为了兼容自身对条件逻辑运算的段式处理(即,把条件间的&&||运算展开为 语言中的if {} else {}语段)。

目前的发展

虽然Vala语言已经诞生了十多年,但其仍在在积极的发展中,不断有新功能引入。在今年的大版本更新中,Vala也加入了大量的、实用的新功能,以下是部分新功能举例:

0.56.0起,Vala支持了在一个函数内部声明嵌套函数:

void write_streams (OutputStream stream1, OutputStream stream2, uint8[] data) {
    void nested_function (Object object_source, AsyncResult result) {
        OutputStream stream = object_source as OutputStream;
        try {
            ssize_t size = stream.write_async.finish (result);
            print (@"Written $size bytes\n");
        } catch (Error e) {
            printerr ("Error writing to stream: %s", e.message);
        }
    }

    stream1.write_async (data, nested_function);
    stream2.write_async (data, nested_function);
}

这样的嵌套函数只在局部有效,与lambda函数类似,嵌套函数可以访问上层函数中的变量,除此之外,还可以在其有效范围内多次调用,弥补了lambda函数一般只能“一次性使用”的不足。

0.56.0起,Vala还引入了部分类的功能,可以将一个class分为多个部分,甚至放到多个文件中进行声明,只需要在声明时加入partial关键字:

public partial class Foo : Object {
    public double bar { get; set; }
}

public partial class Foo : Initable {
    public virtual bool init (Cancellable? cancellable = null) {
        stdout.printf ("hello!\n");
        this.bar = 0.56;
        return true;
    }
}

0.56.0中,Vala还为SequenceArray数据结构增加了foreach的支持,对使用这些数据结构的人带来了更多的便利。

而在下一个版本中,Vala还计划为ISO 646提供支持,从语法上带来更好的体验。

由此可见,直到现在,Vala仍然会积极地加入新功能,保持这门现代语言的先进性。

Vala的维护社区虽然参与人数不及众多热门语言,但Vala社区仍较为活跃,探讨氛围良好。从上文有关编译器bug的部分内容可以发现,在笔者撰写这篇博客的短短的时间里,就有bug从反馈到完成了修复的过程,而这也要归功于社区的积极相应。这样良好的社区环境也进一步确保了Vala语言不会落伍。

我的贡献

Vala编译器是源到源编译器,编译器生成的IR就是C语言的正常代码,可读性较一般IR强;同时,Vala编译器结构较为简单,易于维护。笔者虽然技术水平较低,对Vala也还没有做到完全了解,但也能参与到Vala编译器的维护工作中。

逐字字符串模板

笔者为Vala下一个版本提供的逐字字符串模板功能已经得到合并,该功能支持将Vala的逐字字符串与模板字符串结合使用,其关系类似于Python的rf""之于r""f"",进一步增强了Vala的字符串处理功能,该功能将两者功能结合,在模板字符串中将\视为\字符本身,而非转义字符,例如这样的表达:

void main () {
    double pi = 3.141592653589;
    var s = @"""\pi/ \wow/ \$pi/""";
    print (s);
    // Output:
    // \pi/ \wow/ \3.141592653589/
}

二进制0b与八进制0o整数表达式

笔者为Vala提供了对以0b开头的二进制整数表达式,以及以0o开头的八进制整数表达式的支持。该功能已经提交了PR,目前已经合并。这支持了以下写法:

var a = 0b10101010101;
var b = 0o1234567; // The same as `01234567` but clearer
var c = 0o644u;
var d = -0o755ll;

其中,二进制整数表达式为新增写法,而以0o开头的八进制整数表达式的可读性较原来以单个0开头的八进制表达式可读性更好。笔者提供的这一功能预计会加入到Vala 0.58中。

数字分隔符的支持(待合并)

笔者给Vala增加支持用下划线分隔数字以增强可读性的功能,该功能已经提交PR,等待官方的审核与合并。该功能增加了对以下写法的支持:

int a = 123_456_789;
uint b = 1_000_0000;
double c = 1_500.200_2e-1_000;

但由于之前Vala需要支持形如123_456的变量名,因此这一功能至少不能很快被合并。笔者为此还提交了另一个PR,通过引入编译时警告来逐步解决这一问题。

然而,目前就讨论趋势来看,Vala社区更倾向于使用C23标准的'而不是Python、JS、C#采用的_作为分隔符。笔者个人认为使用'分隔的代码可读性更差,但是这一方案不会破坏Vala变量命名的兼容性。数字分隔符的具体采用方案可能需要后续的进一步讨论。

由于社区较倾向于后一方案,笔者也提交了新的补丁来支持这一方案。

十六进制浮点数支持

这一功能在GNU89及C99的C语言标准中均可用,Vala只需要简单修改就能集成。笔者已经向Vala提交了支持十六进制浮点数的补丁,目前已经合并,将加入到Vala 0.58中。使用十六进制浮点数可以保证浮点数可以精确用二进制表示,没有舍入误差,方便有关浮点数的比对。例如:

var num = 0x1.f23e45a9p0;

string.replace增加替换次数支持

string.replace在Vala中本来的实现为GLib.Regex,即,在每次替换时都根据待替换的字符串内容编译一个GLib.Regex对象。显然这种实现的运行效率非常低,而且,基于正则表达式的替换只能设置需要替换的字符串部分的长度与起始位置,不能实现一般的string.replaceAPI中指定最大替换次数的API。为了解决这些问题,笔者将string.replace用更快的string.splitstring.joinv重新实现,同时,由于string.split支持指定最大分割段数,基于此实现的string.replace也就得以支持指定最大替换次数。

经过相关测试,在字符串长度均较短时,新实现比旧实现快 4.6 倍;在原始字符串很长时,新实现比旧实现快 14.5 倍;在被替换部分的字符串很长时,新实现比旧实现快了 282.6 倍。可见新实现的性能确实显著更好。笔者提交的相关补丁已经得到合并,自Vala 0.58起,string.replace支持设置最大替换次数。

Bug修复及其他修改

笔者为Vala修复了(或协助Vala的其他维护者为Vala修复了)下列Bug:

示例:Windows平台下GTK程序打包器的开发

由于Windows平台没有Linux平台那样基于依赖关系的包管理体系,Windows也不会如大多数Linux发行版一样默认预装GTK,因此Windows下的GTK程序在分发时还需要对依赖进行打包。而该打包流程一般较为复杂。因此,笔者打算开发一个适用于MSYS2环境的Windows平台GTK程序打包器。笔者希望实现一个具有GUI的程序,该程序自身的GUI即可使用GTK实现。

后端

该打包器的后端主要功能为解析二进制依赖以及复制包括程序及其二进制依赖在内的更多文件。解析二进制依赖的工作可以交给ntldd完成。当解析到的二进制依赖文件位于MSYS2目录下时,意味着这一文件需要打包,而MSYS2目录的获取与解析工作均可以使用与Vala集成良好的正则表达式来完成。此外,一般GTK程序依赖的绘制库、主题库也往往需要复制。

由此思路,可以实现以下不完善的后端代码:

namespace GtkPacker {
    public class GtkPacker : Object {
        public string file_path;
        public string outdir;
        string mingw_path = null;
        static Regex msys2_dep_regex {
            get;
            default = /.*(\/|\\)(usr|ucrt64|clang64|mingw64|mingw32|clang32|clangarm64)(\/|\\)/;
        }
        GenericSet<string> dependencies = new GenericSet<string> (str_hash, str_equal);

        public GtkPacker (string file_path, string outdir) {
            this.file_path = file_path;
            this.outdir = outdir;
        }

        void copy_bin_files () {
            string deps_info;

            Process.spawn_command_line_sync (@"ntldd -R '$(this.file_path)'", out deps_info);
            var bin_path = Path.build_path (Path.DIR_SEPARATOR_S, this.outdir, "bin");
            DirUtils.create_with_parents (bin_path, 0644);

            var file = File.new_for_path (this.file_path);
            var target = File.new_for_path (Path.build_path (Path.DIR_SEPARATOR_S, bin_path, file.get_basename ()));
            file.copy (target, FileCopyFlags.OVERWRITE);

            var deps_info_array = deps_info.split ("\n");
            foreach (var i in deps_info_array) {
                var item = (i.strip ()).split (" ");
                if ((item.length == 4) && (!(item[0] in this.dependencies))) {
                    bool condition;
                    if (this.mingw_path == null) {
                        MatchInfo match_info;
                        condition = msys2_dep_regex.match (item[2], 0, out match_info);
                        this.mingw_path = match_info.fetch (0);
                    } else {
                        condition = msys2_dep_regex.match (item[2]);
                    }
                    if (condition) {
                        this.dependencies.add (item[0]);
                        file = File.new_for_path (item[2]);
                        target = File.new_for_path (Path.build_path(Path.DIR_SEPARATOR_S, bin_path, item[0]));
                        file.copy (target, FileCopyFlags.OVERWRITE);
                    }
                }
            }
        }

        static bool copy_recursive (File src, File dest, FileCopyFlags flags = FileCopyFlags.NONE, Cancellable? cancellable = null) throws Error {
            FileType src_type = src.query_file_type (FileQueryInfoFlags.NONE, cancellable);
            if (src_type == FileType.DIRECTORY) {
                string src_path = src.get_path ();
                string dest_path = dest.get_path ();
                DirUtils.create_with_parents(dest_path, 0644);
                src.copy_attributes (dest, flags, cancellable);

                FileEnumerator enumerator = src.enumerate_children (FileAttribute.STANDARD_NAME, FileQueryInfoFlags.NONE, cancellable);
                for (FileInfo? info = enumerator.next_file (cancellable) ; info != null ; info = enumerator.next_file (cancellable)) {
                    copy_recursive (
                    File.new_for_path (Path.build_filename (src_path, info.get_name ())),
                    File.new_for_path (Path.build_filename (dest_path, info.get_name ())),
                    flags,
                    cancellable);
                }
            } else if (src_type == FileType.REGULAR) {
                src.copy (dest, flags, cancellable);
            }

            return true;
        }

        inline void copy_resources() {
            string[] resources = {
                Path.build_path (Path.DIR_SEPARATOR_S, "share", "themes", "default", "gtk-3.0"),
                Path.build_path (Path.DIR_SEPARATOR_S, "share", "themes", "emacs", "gtk-3.0"),
                Path.build_path (Path.DIR_SEPARATOR_S, "share", "glib-2.0", "schemas"),
                Path.build_path (Path.DIR_SEPARATOR_S, "share", "icons"),
                Path.build_path (Path.DIR_SEPARATOR_S, "lib", "gdk-pixbuf-2.0")
            };

            if ("libgtk-3-0.dll" in this.dependencies || "libgtk-4-1.dll" in this.dependencies) {
                foreach (var item in resources) {
                    var resource = File.new_for_path (Path.build_path(Path.DIR_SEPARATOR_S, this.mingw_path, item));
                    var target = File.new_for_path (Path.build_path(Path.DIR_SEPARATOR_S, this.outdir, item));
                    copy_recursive (resource, target, FileCopyFlags.OVERWRITE);
                }
            }
        }

        public inline void run () {
            this.copy_bin_files ();
            this.copy_resources ();
        }
    }
}

笔者计划用GTK4实现其前端,但具体工作尚未完成。待完成后,会将代码和主要思路补充到这里。