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定义的类型,例如
int
是gint
,bool
是gboolean
,等等。 - 内置的数据结构,例如列表
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采用了什么样的约定,从而自动生成对应的面向对象的模型。在上面的例子中,只要知道
- 类名
Foo
; - 类的方法的调用约定:
x.bar(baz)
->Foo_bar(x, baz)
; - 创建函数
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++一样,这里也会遇到拷贝赋值的问题。这里分为两种情况:
- 对象支持引用计数,则拷贝赋值对应于引用计数增加。
- 对象提供了拷贝函数,则拷贝赋值对应于调用该函数。
不满足上述条件的对象无法被拷贝赋值。即便如此,仍然支持两种操作:
- 用一个新的对象覆盖原来的对象。这里并不涉及拷贝的问题。即
x = new Foo(43);
- 用
(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则相反,以实现功能为主要目的,更加实在些(毕竟没有想要成为通用语言的野心)。