Vala语言

Vala是一个建立在GLib上的语言。GLib是基于C语言的基础功能库,和Boost之于C++的地位有几分类似。GLib除了提供了一些数据结构、线程操作、输入输出外,还有一套完全基于C语言的对象系统GObject。在此基础之上,才有了GTK+,这个在Linux上比较流行的图形工具包。

GLib很不错,善用之可以避免重新造很多轮子。GObject可能是摆脱C++后不得已的选择,因为图形界面这样的东西太依赖于面向对象的特性了。它们都基于C,设计得或许很漂亮,可用起来就比较麻烦了,因为C编译器不像C++那样能自动生成代码。例如,C++的shared_ptr可以以几乎透明的方式实现引用计数:每一份拷贝在进入和退出作用域的地方,C++编译器可以自动插入增加引用和减少引用的代码。换作C的话,则需要在代码中明确写出来。

为了减轻开发人员的负担,提高开发效率,Vala被发明出来,它模仿C#的语法,在语法层面上支持面向对象。值得指出的是,Vala的编译器先将vala源文件转换成C源文件,然后调用C编译器来编译。因为幕后是C语言,所以一方面不会带来性能上的退化,另一方面也不会引入兼容性的困难。

下面说说Vala的几个特点。

首先,Vala的基础设施建立在GLib上。具体表现在

  • 所有内置(built-in)类型都是GLib定义的类型,例如intgintboolgboolean,等等。
  • 内置的数据结构,例如列表List,基于GLib的GList,但是语法上增加了泛型的支持。因此用起来要更加方便些。
  • 动态对象的分配和释放,都是使用GLib提供的函数g_malloc, g_free等等。从这个角度看,Vala可以看作是一个对GLib的封装。

其次,Vala的面向对象机制非常有趣。它支持很多种设计。一方面是基于GObject的面向对象模型。如果要实现一些高级的面向对象的功能,这个类必须声明为GObject的子类。这部分可以看作是对GObject的封装。对GTK+的支持也就是从这里而来的。使用Vala编写GTK+程序非常容易:

void main(string[] args)
{
	Gtk.init(ref args);
	var window = new Gtk.Window();
	window.destroy.connect(Gtk.main_quit);
	window.show_all();
	Gtk.main();
}

我还用Vala结合GTK+写了一个Nim游戏作为练习。

另一方面,Vala还可以把很多并非基于GObject的C语言库,用面向对象的方法封装起来。

很多程序库出于兼容性考虑,使用C语言的API,但是设计上采用的是面向对象的思路:

Foo *x = Foo_new(42);
Foo_bar(x, baz);
Foo_free(x);

一般情况下,较少用到高级的面向对象的特性,所以C语言已经足够。使用Vala可以将其封装为

Foo x = new Foo(42);
x.bar(baz);
// 自动释放

和Java/C#的语法很像了。而且可以在作用域的末尾自动释放这个对象,相当于C++中的RAII的思路。

用C++的语法,如果使用RAII,可以表述为

ClassFoo x(42);
x.bar(baz);

问题在于,C++不允许向已有类型绑定新的方法。所以必须另外编写一个新的类,其中包含原来的Foo类型的一个指针,然后,为原来所有的API调用编写对应的方法。即

class ClassFoo {
	Foo *m_ptr;
	Foo(int arg)
	{
		m_ptr = Foo_new(arg);
	}
	~Foo()
	{
		Foo_free(m_ptr);
	}
	int bar(int baz)
	{
		return Foo_bar(m_ptr, baz);
	}
};

诸如此类。但是,写成这样还不够。因为存在析构函数,所以还要编写相应的拷贝构造函数(copy constructor)和拷贝赋值运算符(copy assignment operator),它们被称作C++的三大件(the big three)。这是一件很有挑战性的事情。想要达到的目的是:

Foo x(42); // x.m_ptr = xxx;
Foo y = x; // x.m_ptr = 0; y.m_ptr = xxx;
x = y;    // x.m_ptr = xxx; y.m_ptr = 0;

也就是说,等号右边的变量将要失去对原对象的引用,因为同一时刻只能存在一份引用(类似unique_ptr的语义),否则当作用域退出时,两个对象分别调用他们的析构函数,就会造成一个对象被释放多次,引发内存错误。

与此同时,我们有时候希望用一个新的对象覆盖掉原来的对象,即

Foo x(42);   // x.m_ptr = xxx;
x = Foo(43); // x.m_ptr = yyy; xxx已被释放;

然而,这个功能想要实现却不太容易。我认为这是C++设计上的一个缺陷(C++11引入了一些新机制来解决这个问题,但却让语法更加复杂了)。

Vala采用了另一种策略,不是试图兼容C的语法,然后在此基础上创建一个新的类进行封装,而是描述C的API采用了什么样的约定,从而自动生成对应的面向对象的模型。在上面的例子中,只要知道

  1. 类名Foo
  2. 类的方法的调用约定:x.bar(baz) -> Foo_bar(x, baz)
  3. 创建函数Foo_new, 释放函数Foo_free

只需在.vapi文件中写:

[Compact]
class Foo {
	public int bar(int baz);
	public Foo(int arg);
}

那么Vala编译器就能自动地将vala代码

Foo x = new Foo(42);
x.bar(baz);

转换为C代码

_tmp0_ = Foo_new (42);
x = _tmp0_;
Foo_bar (x, baz);

这是纯粹的代码转换,不会增加额外的胶水代码。当然,机器生成代码未必适合人阅读,但编译后的效果是一样的。

和C++一样,这里也会遇到拷贝赋值的问题。这里分为两种情况:

  1. 对象支持引用计数,则拷贝赋值对应于引用计数增加。
  2. 对象提供了拷贝函数,则拷贝赋值对应于调用该函数。

不满足上述条件的对象无法被拷贝赋值。即便如此,仍然支持两种操作:

  1. 用一个新的对象覆盖原来的对象。这里并不涉及拷贝的问题。即
x = new Foo(43);
  1. (owned)修饰符转移所有权。即
x = (owned) y;

这样y的值将传递给x,自己则变为null

C++11发明了一些新的规则(移动构造函数,移动赋值运算符)来实现类似的语义。它试图用一种通用的规则,从原理上处理这个问题。一定程度上讲,继承了C语言“只提供机制,不提供政策”的哲学。

对于C和C++而言,很多看似不解的问题,只要从大的规则向下推理,答案总是很清楚,没有什么例外。这是这两个语言美的地方。为什么C++98中x = Foo(43)不可行?因为表达式Foo(43)的类型是Foo,和其他相同类型的表达式(例如,y),地位上没有任何区别。而这是一个赋值表达式,按照语法规则,必须调用Foo类中的拷贝赋值运算符。如果没法调用这个运算符,那就没有办法了。虽然大家心里都清楚,Foo(43)只是一个临时的对象,这个赋值只是为了重写x罢了,不存在“拷贝”的问题(和x = y不同,这个赋值之后显然多了一份拷贝),但是C++编译器的目的不是“尽最大的可能实现代码的语义”,而是“严格遵守语法规则来解释代码”。就是那样不近人情,一丝不苟。

而Vala则相反,以实现功能为主要目的,更加实在些(毕竟没有想要成为通用语言的野心)。


分享