水言木 · 做一个真正的blogger

水言木 做一个真正的blogger

C#字符串与编码 (上)

C#字符串

我们都知道,C# 中System.Char用来表示一个字符,它是一个 16 位的值类型,而一个字符串 (String) 则是连续的一串字符,我们可以通过String.Length属性来获取字符串的长度。那么,下面的代码的会输出什么呢?

Console.WriteLine("𠬠".Length);

答案是 2。呃,怎么不是 1? 好像有点不太合逻辑。

要回答这个问题,需要知道Char到底是一个什么东西。MSDN对Char的描述是:

Represents a character as a UTF-16 code unit.

UTF-16 code unit? 所以,我们还要从 UTF-16 说起。

Unicode, UTF-32 和 UTF-16

早期美帝的程序员没有意识到英语只是全世界所有语言中的一种,他们以为26个英文字母再加上一些其它符号就够用了,所以就有了当时的 ASCII。但是随着互联网的发展,他们终于意识到软件原来还是需要给不同国家不同语言的人来使用的,所以就开始有了其它的编码方法,但因为缺少一个一统天下的标准,所以乱码问题非常常见 (相信不少码农在初入行时都有被乱码问题折磨过的经历)。

而 Unicode 就是要来解决这个问题,它是一个字符集,目标是定义一个能满足全人类需要的字符集合,除了定义哪些字符会被涵盖外,它还要定义每个字符所对应的码位 (Code point 或 Code position)。

什么是码位?
Unicode 字义了字符集合后,需要为每个字符指定一个数字,这样计算机才有办法处理。假如字符集中有 1 万个字符,那就需要 1 万个数字,每个字符对应一个数字,这所有的 1 万个数字就构成了编码空间 (Code space),而每个数字就是对应的字符的码位。

假设字符集前三个字符是 A, B, C,我们用 0 到 9999 来编码,那 A 对应 0,B 对应 1, C 对应 2。0 就是 A 的码位,1 就是 B 的码位,以此类推。码位也就是字符在编码空间中的位置,所以叫码“位”。

在 Unicode 标准中,编码空间由 0x0000 - 0x10FFFF 之间的整数构成,一共可以容纳 1,114,112 个码位 (17 × 216),目前 (Unicode 7.0) 已分配字符的还不到一半

好了,到现在为止,我们讨论的还只是字符集,没有涉及具体的编码。Unicode 定义了三种编码形式 (Encoding forms),分别是 UTF-32, UTF-16 和 UTF-8,从 C# 开发人员的角度,Unicode 字符集就好像是一个接口,规定了所有“实现类”必须能正确编码的所有 Unicode 中定义的字符,而 UTF-32, UTF-16 和 UTF-8 则像是实现类,它们都可以编码 Unicode 中定义的字符,但编码方法则不一样。

UTF-32

Unicode 的编码空间为 0xFFFF - 0x10FFFF,那可以想到的最简单的办法就是让每个码位对应一个 32 位 (4 bytes) 二进制数,这就是 UTF-32 编码。所以在 UTF-32 中,每个字符占用 4 个字节。

var encoding 
	= new UTF32Encoding(bigEndian: true, byteOrderMark: false); 
							
var bytes = encoding.GetBytes("ABC");
var hex = BitConverter.ToString(bytes);

// 输出 12 bytes
Console.WriteLine(bytes.Length + " bytes");

// 输出 00-00-00-41-00-00-00-42-00-00-00-43
Debug.WriteLine(hex);

上面的 C# 代码使用System.Text.UTF32Encoding类对字符串”ABC”进行编码,编码得到的字节数组长度为 12,即每个字符占用 4 个字节。即使是”A”这么简单的字母,UTF-32 中都要占用 4 个字节 (在 ASCII 中只要一个字节),这无疑是一种巨大的浪费,所以 UTF-32 在实际中用的不多。

UTF-32 对每个码位都使用 32 位 (4 字节) 进行编码,也就是说,32 位是 UTF-32 的最小编码单元 (Code unit),如果有人给我一个 10 字节的字节数组,说这是 UTF-32 对某个字符串的编码结果,让我猜猜源字符串是什么,那我一定拿臭鸡蛋砸他,居然敢骗我,UTF-32 编码的结果怎么可能是 10 字节。

下篇再细说上面代码中的bigEndianbyteOrderMark

UTF-16

事实上,Unicode 中常用的字符定义在 0x0000-0xFFFF 之间,那我们是不是可以对常用字符做一些优化呢?是的,这就是UTF-16。

在UTF-16中,码位在 0x0000 - 0xFFFF 之间的字符用两字节来编码,比如“我”,Unicode 码位是 U+6211,在 UTF-16 中也编码为 0x6211。而对于 0xFFFF 以上的码位 (显然两字节不够用了),UTF-16 用四个字节来编码 (Surrogate pairs)。例如文章开头的“𠬠”,Unicode 码位是 U+20B20,在 UTF-16 中编码为 0xD842 0xDF20。我们可以用 C# 代码来试验一下:

var encoding 
	= new UnicodeEncoding(bigEndian: true, byteOrderMark: false);
						
var bytes = encoding.GetBytes("ABC");
var hex = BitConverter.ToString(bytes);

// 输出 6 bytes
Console.WriteLine(bytes.Length + " bytes");
// 输出 00-41-00-42-00-43
Console.WriteLine(hex);

bytes = encoding.GetBytes("我");
hex = BitConverter.ToString(bytes);
// 输出 62-11
Console.WriteLine(hex);

bytes = encoding.GetBytes("𠬠");
hex = BitConverter.ToString(bytes);
// 输出 D8-42-DF-20
Console.WriteLine(hex);

上面的代码使用了 .NET 中的System.Text.UnicodeEncoding类,它实际上指的是 UTF-16,UnicodeEncoding这个命名有一定的误导性 (历史原因),其类名中的 Unicode 和我们前面说的 Unicode 不是一回事。

所以 UTF-16 是一种变长编码 (UTF-32 是定长编码),它的编码单元是 16 位 (2 字节),对于0xFFFF 以上的码位,需要使用两个编码单元。

再说 C# 字符串

// 输出 2
Console.WriteLine("𠬠".Length);

好了,现在我们来看看为什么文章开头的代码输出结果是 2 而不是 1。

我们先看下 MSDN 是怎么对Char进行描述的:

Represents a character as a UTF-16 code unit.

C# 使用 UTF-16 来表示字符串 (Java 和 JavaScript 也一样),一个字符串则由一串连续的Char组成,而一个Char则表示了一个 UTF-16 编码单元 (Code unit)。

前面说了,UTF-16 的编码单元是 16 位 (这就是为什么Char是 16 位的值类型),而 16 位的编码单元只能表示码位在 0x0000 - 0xFFFF 之间的 Unicode 字符,对于码位在 0xffff 以上的字符,UTF-16 需要用两个编码单元,也就是两个Char来表示。所以,Char和我们所认为的字符并不一定是一一对应的关系

"𠬠".Length输出”𠬠”这个字符串所包含的Char的数量,而“𠬠”的 Unicode 码位是 U+20B20,在 UTF-16 使用两个编码单元 (0xD842 0xDF20) 进行编码,所以它需要两个Char,第一个Char用来表示 0xD842,第二个Char用来表示 0xDF20。可以通过程序来证明我说的是真的:

var str = "𠬠";

var hexChar1 = ((int)str[0]).ToString("x");
var hexChar2 = ((int)str[1]).ToString("x");

// 输出 d842 df20
Console.WriteLine(hexChar1 + " " + hexChar2);

而对于其它字符,比如”A”,在 UTF-16 中只使用了一个编码单元,所以用一个Char就可以了,这就是为什么"A".Length的结果是 1。

总结

直觉上,我们很容易会以为 C# String.Length属性会输出字符串中我们所认为的字符的数量,而事实上,它只是输出Char的数量,而一个Char只是对应了一个 UTF-16 编码单元,它和我们所认为的字符并不一定有一一对应的关系。明白了这一点,就知道为什么"𠬠".Length的结果是 2 这种“奇怪”的事了。

参考资料

C#中的协变与逆变(续)

有同学反馈上一篇关于协变与逆变的文章不好理解,所以本文换个角度来说明。

一切都是为了”类型安全”

在理解什么协变与逆变之前,需要明白,在 C# 中,”类型安全”是非常重要的。

曾经我们抱怨为什么 C# 不支持把IEnumerable<Cat>类型的对象赋给声明为IEnumerable<Animal>的变量(CatAnimal的子类),C# 团队听到了(可能本来就在计划之中),所以在 C# 4 中这个问题就不存在了,但我们不禁要问,既然支持把IEnumerable<Cat>赋值给IEnumerable<Animal>,为什么不同时支持把IList<Cat>赋值给IList<Animal>

答案是为了类型安全。假如支持把IList<Cat>赋值给IList<Animal>,那下面的代码就可以正常通过编译:

IList<Cat> cats = ...;
IList<Animal> animals = cats;

animals.Add(new Tiger());

但问题是,运行时上面代码的最后一行会抛出一个类型不匹配的异常,也就是说,上面的代码类型不安全。C# 想尽力避免这样的情况发生,让尽可能多的错误在编译时(而不是运行时)就可以被发现,所以它干脆就不允许把IList<Cat>赋给IList<Animal>,这样出现运行时错误的机会就会大大下降。

可以发现,上面的代码之所以会有运行时错误,主要原因在于IList<Animal>.Add方法的参数可接受任意的Animal参数,如果泛型接口的泛型参数不用在方法参数中,而只用在方法返回值中,会不会有问题呢?答案是没有问题,比如IEnumerable<T>:

IEnumerable<Cat> cats = ...;
IEnumerable<Animal> animals = cats;

// 这里除了遍历 animals 还是只能遍历 animals

可以发现,如果把IEnumerable<Cat>赋给IEnumerable<Animal>,那我们除了遍历IEnumerable<Animal>还是只能遍历IEnumerable<Animal>(因为IEnumerable<T>上没有定义添加元素的方法),自然就不会有上个例子中的运行时错误,所以 C# 4 支持把IEnumerable<Cat>赋给`IEnumerable'。

为什么IList<T>不继承IList

IEnumerable<T>接口继承了非泛型的IEnumerable,那为什么看起来那么相似的IList<T>不继承非泛型的IList?

答案也是为了类型安全。非泛型IList.Add()方法的参数类型是Object,如果IList<T>继承IList,那就意味着不管T是什么类型,调用者都可以把任意对象添加到IList<T>中,编译可以通过,但运行时就会报错。而非泛型IEnumerable没有定义添加元素的方法,自然不会存在这个问题。

为什么List<T>同时实现了IList<T>IList
这主要是为了更好地兼容 C# 1.0 (无泛型)。另外,List<T>是实现类,不是接口,我们可以发现List<T>是显式实现了IList,所以对于使用者来说,若不是把List<T>强制转换为IList,是看不到非泛型的Add方法的。

协变与逆变

协变与逆变是比较理论化的术语,如果因为Cat可以赋给Animal,所以IEnumerable<Cat>也可以赋给IEnumerable<Animal>,那我们就把这种能力称为协变;如果因为Cat可以赋给Animal,所以IEnumerable<Animal>可以赋给IEnumerable<Cat>,我们就把这种能力称为逆变。

但上面这样的描述无法揭示协变与逆变的本质,所以我们对这个描述进行泛化:

假设存在 T 到 T' 的一个映射,记为 T -> T',
然后在 T 和 T' 上分别应用一个操作 F,

(1) 如果 F(T) -> F(T') 成立,则称 F 操作是协变的(映射方向不变);
(2) 如果 F(T) <- F(T') 成立,则称 F 操作是逆变的(映射方向反转);

T替换成CatT'替换成Animal,”映射”替换成”赋值”,F替换成IEnumerable<>,就变成:

Cat 可以赋值给 Animal,
然后把 Cat 和 Animal 分别套上 IEnumerable<>

(1) 如果 IEnumerable<Cat> 可以赋值给 IEnumerable<Animal>  --> 协变
(2) 如果 IEnumerable<Animal> 可以赋值给 IEnumerable<Cat>  --> 逆变

显然,将IEnumerable<Animal>赋给IEnumerable<Cat>类型不安全,所以 C# 不支持IEnumerable<T>上的逆变(但支持协变)。而对于Action<T>,则支持T上的逆变而不支持协变,原因同样也是因为类型安全性,就不赘述了。

总结

协变与逆变本身并没有什么可不可以之说,如果能把IEnumerable<Animal>赋给IEnumerable<Cat>,那这种能力就称为逆变,只是这种逆变会导致代码类型不安全,所以 C# 不支持而已。明白 C# 中类型安全的重要性,应该就能更好地理解 C# 4 中的协变和逆变。

参考资料

C#中的协变与逆变

个人感觉协变(Covariance)与逆变(Contravariance)是 C# 4 中最难理解的一个特性了,因为 C# 4 用了一个非常直观的语法(inout关键字),在很多情况下,这似乎很简单,in用于输入的参数,out用于输出的返回值,但事实上不完全如此,比如Method(Action<T> action)(会让人抓狂,一会再说)。这也是困扰了我相当久的问题,所以今天打算分享一下我自己的理解。

协变与逆变

我们先引入一些记号,假设 T 和 U 是两个类型,那它们之间会有几种关系:

T < U
T > U
T = U
T 和 U 无关

比如AnimalCat两个类型,CatAnimal的子类,那我们就记为Animal > CatCat < Animal,可以理解为,Animal表示所有的动物,而Cat只表示”猫”,Animal可以表示的范围比Cat更广,所以Animal > Cat

现在假设我们分别在 T 和 U 上应用一个操作,我们用 f 函数来表示这个操作,即应用了 f 以后,T 和 U 对应地变成 f(T) 和 f(U)。

如果应用了 f 操作以后,T 和 U 的大小关系被保留了下来,就称这个操作是协变的;反之,如果 T 和 U 的大小关系被反转了,就称这个操作是逆变的

协变:T < U,应用 f 操作后,f(T) < f(U)
逆变:T < U,应用 f 操作后,f(T) > f(U)

这可能有点抽象(但很重要,是后续内容的基础),我们举一个 C# 数组的例子。

把上面的 T 替换成 Cat,U 替换成 Animal,用大小关系来表示,即 Cat < Animal,然后把 f 操作替换为”数组化”,也就是说,应用了数组化操作后,Cat就成变Cat[]Animal就变成Animal[]

C# 从 1.0 开始就支持数组上的协变,这是什么意思呢?用我们上面提到的协变和逆变的定义,它可以描述为:

数组上的协变:
Cat < Animal,所以 Cat[] < Animal[]

我们都知道,在 C# 中,如果CatAnimal的子类,即Cat < Animal,那下面的语法是合法的:

Cat cat = ...;
Animal animal = cat;

也就是说,如果两个类型 T 和 U 满足 T < U,那么下面的代码是合法的:

T obj = ...;
U u = obj;

前面我们说了,C# 从 1.0 开始就支持数组上的协变,也就是说,如果Cat < Animal,那么Cat[] < Animal[]就可以成立,那是不是意味着,我可以将一个Cat数组赋值给一个Animal数组呢?答案是确定的:

// 定义 Cat 数组
Cat[] cats = new[] { new Cat { Name = "Kitty" } };

// 将 Cat 数组赋值给 Animal 数组
Animal[] animals = cats;

上面的代码可以编译通过,也可以正常的跑起来。这就是 C# 1.0 在数组上对协变的支持。

类型安全

C# 是一门类型安全的语言,比如Animal animal = new Person()这样的代码是没办法通过编译的(Person类型和Animal不兼容),C# 语言在设计上就在尽可能地避免类型不安全的发生,但可惜的是,数组上的协变不是类型安全的协变,我们可以通过一个例子来看:

// 定义 Cat 数组
Cat[] cats = new[] { new Cat { Name = "Kitty" } };

// 将 Cat 数组赋值给 Animal 数组
Animal[] animals = cats;

// 修改 Animal 数组的第一个元素
animals[0] = new Tiger { Name = "Tiger Lei" };

上面的代码是可以通过编译的,但是运行时,会抛出一个System.ArrayTypeMismatchException的异常。在对数组的协变性的支持上,C#编译器团队曾经是有过争议的,但是由于其它一些原因,还是加上了。

但这并不意味着 C# 会从此毫不顾忌的支持一切协变,比如Action<>上的协变就不被也永远不会被支持,试想一下,如果支持Action<>操作的协变,那会是怎样?

按前面说的,已知Cat < Animal,若支持Action<>操作上的协变,则有Action<Cat> < Action<Animal>,那就意味着下面的代码是合法的:

Action<Cat> miao = cat => cat.Miao();
Action<Animal> action = miao;

action(new Tiger());

如果支持Action<>上的协变,则上面的代码可以通过编译,但很明显,最后一行在执行时会抛出一个运行时的异常,因为Tiger虽然长得有那么点像猫,但人家可不会Miao的叫啊。所以,C# 是不会允许这种情况发生的,所以上面的代码在实际中会编译错误(不管是哪个版本的编译器)。

既然无法在Action<>上支持类型安全的协变,那可以支持类型安全的逆变吗?我们可以来试一下,已知Cat < Animal,假设支持Action<>操作的逆变,则有Action<Cat> > Action<Animal>,那就意味着下面的代码是合法的:

Action<Animal> sayHello = { it => Console.WriteLine(it.Name); };
Action<Cat> catSayHello = sayHello;

catSayHello(new Cat());

sayHello这个委托永远都只会调用Animal上的属性和方法,而我们永远都只会向catSayHello传入CatCat的子类。sayHello既然可以处理Animal,那一定可以处理Cat,所以,上面的代码是类型安全的,也就是说,Action<>上的逆变是类型安全的。

虽然Action<>上的逆变是类型安全的,但在 C# 4.0 之前,你没有办法在代码中使用这种逆变性,所以大家可能会发牢骚,把Action<Animal>赋给Action<Cat>明明是类型安全的,会什么编译器不让我通过!不过幸运的是,C# 4.0 开始,类型安全的协变和逆变都得到了支持,但要注意的是,我们在 C# 4.0 中谈到的对协变和逆变的支持,都是在”类型安全”的前提下,类型不安全的协变和逆变是不支持的,并且,我们谈的都是对泛型参数的协变性和逆变性的支持。

协变逆变与泛型参数位置

(1) 泛型参数若处于输出的位置,那它的协变性是类型安全的。

例如:

public interface IEnumerator<T>
{
	T Current { get; }
}

public interface IEnumerable<T>
{
	IEnumerator<T> GetEnumerator();
}

public delegate TResult Func<TResult>();

IEnumerator<T>IEnumerable<T>Func<T>中的T,都是处于”输出”的位置,所以T是可以支持类型安全的协变的,我们可以试一下,已知Cat < Animal,若支持IEnumerable<>操作上的协变,则IEnumerable<Cat> < IEnumerable<Animal>,那按照”小的”可以赋值给”大的”的原则:

IEnumerable<Cat> cats = ...;
IEnumerable<Animal> animals = cats;

// 接下来随便对 animals 怎么操作,都是类型安全的,强制类型转换除外

同样对,对于Func<T>,已知Cat < Animal,那Func<Cat> < Func<Animal>,因此:

Func<Cat> findCat = () => new Cat();
Func<Animal> findAnimal = findCat;

Animal animal = findAnimal();
// 接下来不管怎么对 animal 操作,都是类型安全的,强制类型转换除外

(2) 若泛型参数处于输入的位置,则它的逆变性一般是类型安全的(不完全成立,但是我们先这么认为)。

例如:

public interface IComparer<T>
{
	int Compare(T x, T y);
}

public delegate void Action<T>(T obj);

IComparer<T>Action<T>中的T都是处于输入的位置,所以它们的逆变性都是类型安全的。我们可以试一下,已知Cat < Animal,若支持IComparer<>操作上的逆变,则有IComparer<Cat> > IComparer<Animal>,也就意味着下面的代码是合法的:

IComparer<Animal> animalComparer = ...;
IComparer<Cat> catComparer = animalComparer;

catComparer(new Cat(), new Cat());

animalComparer可以处理任意的动物 (Animal),而我们只可能向catComparer传入CatCat的子类,既然animalComparer可以处理任意的动物,那当然就可以处理任意的猫了,所以上面的代码是类型安全的。Action<>前面已举过例子,不再重复。

因此,我们可以得到一个大致的结论,如果泛型参数处于输出的位置,那它就可以支持类型安全的协变,若泛型参数处于输入的位置,就可以支持类型安全的逆变(不完全正确,后面再细说),这也就是为什么 C# 用out来表示对应的泛型参数支持协变,而用in来表示对应的泛型参数支持逆变。outin显然比协变和逆变这样的术语来得通俗易懂,所以这也是 C# 设计团队的聪明之处。

抓狂的时候到了

如果说上面的内容都很好理解,那接下来的这个例子也许就会让人抓狂了。

前面说过,当泛型参数处于”输入”的位置时,它的逆变是类型安全的,这时只要在相应的泛型参数前加个in关键字,就可以让它支持逆变,C# 编译器就不会为难我们,但C# 编译器真的这么仁慈吗?

public interface IFoo<in T> { }

public interface IBar<in T>
{
    void Method(IFoo<T> foo);
}

IBar<T>中,T 是处于输入的位置,所以上面的代码理应会在 C# 4.0 的编译器下编译通过,但事实上,我们会得到一个编译错误。好吧,和前一节讲的不一样,这是为什么?要怎么做才能让它编译过过?

为简化问题,我们用X来代指 IFoo<T>,用X'来代指 IFoo<T'>'没有特殊含义,如果不觉得太多字母迷乱双眼的话,也可以用YA啊什么的),即:

X  = IFoo<T>
X' = IFoo<T'>
public interface IFoo<in T> { }

// 下面的问号有三种可能性,
// in, out 或什么都不加(不可变)
// 接下来我们会推导出一个合适的结果
public interface IBar<? T>
{
	void Method(X foo);
}

对于IBar来说,泛型参数X处于输入的位置,这和上一节中提到的IComparer<X>的情形是一样的,所以对于X来说,是可以支持类型安全的逆变的(注意,是X,不是T)。根据X的逆变性:

如果 IBar<X>  <  IBar<X'>,则必有 X  >  X';
如果 IBar<X>  >  IBar<X'>,则必有 X  <  X';

我们下面只取第一种情况进行推导(两种情形可推出一致的结论):

[1] 因为 IFoo<T> 中的 T 是逆变的(根据 IFoo<in T> 接口定义),因此,
[2] 若 IFoo<T>  >  IFoo<T'>,则必有 T < T'

[3] 若 IBar<X>  <  IBar<X'>,
[4] 因为 IBar<X> 上的 X 支持类型安全的逆变,因此,
[5] 必有 X  >  X',即 IFoo<T>  >  IFoo<T'>
[6] 根据 [2] 中的结论,
[7] 必有 T < T'

接下来是见证奇迹的时刻,把上面推导过程的第[3]和第[7]行留下,其它的全部抹掉,就变成:

如果 IBar<X>  <  IBar<X'>,
必有 T < T'

看到了吗?要让IBar<X> < IBar<X'>成立,T < T'必须成立,也就意味着,如果把T作为IBar的泛型参数,那T只能支持类型安全的协变,而我们一开始的代码中,IBar<T>中的T被标记为in(要求支持逆变),当然编译器就不答应了,如果我们把它改成out(要求支持协变),那编译器就没有意见了,因为根据前面的推导,这样是类型安全的。

所以,可以编译的代码应该是:

public interface IFoo<in T> { }

public interface IBar<out T>
{
    void Method(IFoo<T> foo);
}

当然,每次这么推导也是很痛苦的,但是我们可以记住,把T直接作为输入参数时,那它就可以支持逆变,但如果T上被套了另一个操作,比如IFoo<T>,那可变性就会被扭转。所以,上面代码中的inout互调位置后也可以编译通过。不过这对于方法返回值则不会有这种”扭转“。

不可变 (Invariance)

一个泛型参数如果既是输入参数,又是输出参数,那它无法支持协变和逆变(即不可变),例如 .NET 框架中的IList<T>接口的T即是不可变的,因为无法同时保证它的协变和逆变都是类型安全的。

##总结 ##

如果你使用的是 C# 4.0+,即使你没听说过协变和逆变,也很可能已经在你的代码中大量地用到了协变和逆变,例如当你把一个IEnumerable<Cat>赋值给一个IEnumerable<Animal>的时候,这在早期的 C# 中是不允许的。但把协变和逆变理解清楚却不是件容易的事,至少对我来说,困惑了我太久。

参考资料

JavaScript对象模型

JavaScript 因为其 Java 前缀,使得这门语言变成最让人误解的语言。而作为一门面向对象的编程语言,则容易让来自于其它如 Java 或 C# 背景的人员在 JavaScript 中寻找“类”的踪迹。

前人已经告诉我们, JavaScript 中没有 class,但是它有 function,相当于 class 的效果,比如:

function Animal() {
    this.name = null;
    
    this.sing = function () {
        console.log('Singing ' + this.name);
    }
}

var cat = new Animal();
cat.name = 'Kitty';
cat.sing(); // 输出 Singing Kitty

上面的代码定义了一个 Animal 的类,包含 name 属性和 sing 方法。类似于 Java,它使用 new 关键字来创建类的实例,同时通过点号来调用属性和方法。

继承

作为一门面向对象编程语言,继承是必须的。JavaScript 通过原型链 (Prototype Chain) 来实现继承。现在我们来定义一个 Cat 子类:

function Cat() { 
	this.jump = function () { }
}

Cat.prototype = new Animal;

var cat = new Cat();
cat.name = 'Kitty';
cat.sing();

新定义的 Cat 只有一个 jump 方法,没有 sing,但上面的代码运行后,同样会输出 “Singing Kitty”,而不会报错,这说明 sing 方法已经从 Animal 继承下来了,而这一切,是通过Cat.prototype = new Animal做到的。

每个 JavaScript 对象都有一个__proto__属性,它是一个对象,是与生俱来的,不用显式去定义,新建的对象的__proto__都是指向了 Object 的 prototype,这也是为什么所有对象都可以调用 toString 等方法。

而重点在于,__proto__是一个特殊属性,当我们调用对象上的属性或方法时,JavaScript 会先在对象上找相应的属性或方法,如果没找到,就会顺着其__proto__去找,若还没找到,就再顺着其__proto____proto__去找,这就形成了一个原型链,原型链最终会以 null 结束。

如果上面代码中没有Cat.prototype = new Animal这一行,JavaScript 找不到 sing 方法,会抛出一个错误,但幸运的是,我们通过Cat.prototype = new Animal修改了 Cat 的 prototype,所以 JavaScript 在 cat 对象的 prototype 上找到了 sing 方法。

上面我们定义出来的对象结构构造出来的原型是这样的:

{ jump } --> { name, sing } --> ... --> null

类似的,我们还可以继续定义 Cat 的子类:

function RobotCat() {
    this.os = 'Andriod';
}

RobotCat.prototype = new Cat;

var cat = new RobotCat();
cat.name = 'Kitty';
cat.sing();

于是原型链就变成了:

{ os } --> { jump } --> { name, sing } --> ... --> null

当然,我们也可以重写一个方法,比如:

RobotCat.prototype.sing = function () {
    console.log('Sorry, I cant sing.');
}

这时再调用 cat.sing 就变成了输出 “Sorry, I cant sing”。方法重写并不是特例,画出原型链就知道为什么可以实现方法重写:

{ os } --> { jump, sing } --> { name, sing } --> ... --> null

{ jump, sing } 对应的是 RobotCat 的 prototype,而 { name, sing } 对应的是 Animal,它现在有自己的 sing 方法了,所以 JavaScript 在检查 RobotCat 的 prototype 时就找到了 sing,也就不会再去 Animal 上面找了。

JavaScript对象模型

上面我们用 JavaScript 的 function 去“模拟”了“类”的效果,并且用原型链实现了继承。说得仿佛 JavaScript 是一个天生有缺陷的孩子,只能通过一些 hack 的手段来模拟面向对象的效果。但 JavaScript 是一门面向对象编程语言,只不过它不是我们所熟悉的基于类,而是基于原型。这也说明它和 Java 除了语法相似,根本就是两门完全不同的语言。

在基于类的语言中,“类”和“实例”是两个独立的概念,我们先定义类,然后再创建类的实例。而在 JavaScript 中,则没有这种区别。

前面例子中我们用 function 来模拟“类”,然后用 new 创建这个模拟“类”的实例,但现在我要说,我们定义的 function,本身也是一个对象。什么?!好凌乱的感觉。

JavaScript 有两种类别的数据类型,一种是 Primitive Type,包括 Boolean, null, undefined, Number, String, Symbol (ES6),另一种是 Object,而 Function 则属于 Object 的一种,注意,这里的 Function 用的是大写 F。

我们在代码中写下一个 function 时,在效果上,相当于创建了一个 Function 对象,例如:

function hello(name) {
    console.log('Hello, ' + name);
}

hello('Mouhong');

上面的代码相当于:

var hello = new Function('name', 'console.log("Hello, " + name)');
hello('Mouhong');

两段代码不同的地方在于写法和性能(通过 function 定义的函数会有优化),但它们都是在创建 Function 对象,换句话说,在 JavaScript 中,函数就是一种对象,这也能解释为什么我们可以把一个函数赋给一个变量:

var func = function (name) {
    console.log('Hello, ' + name);
};

func('Mouhong');

上面我们把一个函数赋给了 func 变量,再次重申一下,我们这里定义了一个函数,但实际上是创建了一个 Function 对象,Function 对象和其它对象的不同之处在于,它可以被调用,所以它被称为 Callable Object。

回到前面我们”模拟“出来的 Animal 类:

function Animal() {
    this.name = null;
    
    this.sing = function () {
        console.log('Singing ' + this.name);
    }
}

我们说 Animal 类中定义了一个 sing 方法,而实际上,JavaScript 并没有区分属性和方法,对 JavaScript 来说,Animal 中的 name 和 sing 都是属性,sing 属性的值是一个 Function 对象,仅此而已。

new关键字

使用 new 关键字来创建一个对象的时候,都发生了什么呢?继续拿 Animal 举例:

function Animal() {
    this.name = null;
    
    this.sing = function () {
        console.log('Singing ' + this.name);
    }
}

var cat = new Animal();
  1. 当 JavaScript 看到 new 关键字时,它先创建一个空对象;
  2. 将新创建的对象绑定到 this,然后执行 Animal 函数;
  3. Animal 函数体开始执行,在 this 上添加 name 和 sing ,注意此时 this 是新创建的对象,所以相当于在第1步中创建的对象上添加属性。同时将 this 对象的 prototype 设置为 Animal.prototype (不是把 prototype 设置成 Animal 对象);

可以用代码来模拟上面的过程:

var cat = {};
Animal.call(cat);
cat.__proto__ = Animal.prototype;

不过要注意,设置新创建对象的 prototype 是很重要的,还记得我们的原型链吗?假设 Animal 的原型链上有其它属性,而在创建 cat 对象时没有把 cat 的 prototype 设置为 Animal 的 prototype,那 cat 就没办法把 Animal 的”基类属性”给继承下来。自动设置 prototype 的过程只有用 new 时才会发生,所以上面的模拟代码中,我们要手工设置 __proto__

此时的原型链如下所示:

{ name, sing } --> { call, apply, ... } --> { toString, ... } --> null

后三项有点晕,是吗?还记得我们提到过,用 function 来定义一个函数,相当于是 new 出一个 Function 对象,所以我们把代码转换成下面的形式:

var Animal = new Function('\
                            this.name = "Kitty";\
                            this.sing = function () {\
                                console.log("Singing " + this.name);\
                            }'
                );

var cat = new Animal();

然后再用 JavaScript 代码模拟出其执行流程:

var Animal = {};
Function.call(Animal, '...'); // 这里忽略函数体字符串
Animal.__proto__ = Function.prototype;

var cat = {};
Animal.call(cat);
cat.__proto__ = Animal.prototype;

Function.prototype 中定义了 call, apply 等方法,而 Function.prototype 又是基于Object.prototype 构建出来的,而 Object.prototype 又包含了 toString 等,所以,原型链就变成了:

cat { name, sing } 
  --> { call, apply, ... } 
    --> { toString, ... } 
      --> null

另外,当我们知道 JavaScript 中没有所谓的“类”,连 function 都是一个对象时,也可以发现,通过设置一个对象的 prototype,可以非常方便的让一个对象从另一个对象上“继承”一些东西,比如:

var Thing = {
    die: function () {
        console.log('I am dying...');
    }
};

function Animal() {
    this.name = null;

    this.sing = function () {
        console.log('Singing ' + this.name);
    }
}

Animal.prototype = Thing;

var cat = new Animal();
cat.die(); // 输出 I am dying...

从 C# 码农的背景来看,上面的 Thing 可以充当“静态类”的效果,但是既然我们在写 JavaScript,还是要从 JavaScript 的角度来看,不要硬往其它语言上面靠,所以 Thing 是一个对象,它有一个 die 属性,属性值是一个 Function 对象,而通过设置 Animal.prototype = Thing,Animal 就把 die 从 Thing 上继承下来了。

当我们了解清楚 JavaScript 的原型链,就会发现它有多么的灵活。JavaScript 并没有限制一定要用怎么实现继承,只要设置对原型链,就可以达到我们的效果。

比如,我们可以这样:

function Animal() {
    this.name = 'Kitty';

    this.sing = function () {
        console.log('Singing ' + this.name);
    }
}

function Cat() {
    Animal.call(this);
    this.jump = function () { }
}

Cat.prototype = Object.create(Animal.prototype);

var cat = new Animal();

cat.sing(); // 输出 Singing Kitty

主要的不同点在于,Cat 函数的第一行,我们调用了 Animal.call(this),回忆一下刚才 new 的原理,可以画出 var cat = new Animal() 的执行流程:

var cat = {};
Cat.call(cat);
  - Animal.call(cat)
    - cat.name = 'Kitty'
    - cat.sing = function () { }
  - cat.jump = function () { }
  - cat.__proto__ = Cat.prototype

所以,最终 cat 对象就变成了这样:

var cat = {
	name: 'Kitty',
	sing: function () { },
	jump: function () { },
	__proto__: { }
}

总结

JavaScript 看起来像 Java,但和 Java 完全不相干(硬要扯上点关系的话,大概就是当年 JavaScript 想借 Java 之名火一把,人家本来叫 LiveScript)。

从 Java 的角度,可以认为 JavaScript 的 function 相当于是“类”,但其实更适合把 function 看成构造函数,事实上,它也被称为 Constructor Function。如果从 Java 的背景,也许不好理解一个构造函数如何能够脱离于类而存在,但函数在 JavaScript 中是一等公民,所以这也不奇怪。

从另一个角度,Java 中的类封装了函数和状态,而 JavaScript 通过闭包,也能把函数和状态封装在一起,不也就实现了 Java 中的类的效果。当然,闭包就是另一个话题了。

参考资料

[1] Details of the Object Model

你好,新博客

说起博客,我显然不是一个新手,我大学时就开始写博客,但这不是件值得自豪的事,我一共搭建过六七个博客,但每个博客都很短命,换句话说,是没有办法坚持写。而每次中断一大段时间后又重新燃起写博的冲动时,往往又会对原博文和界面相当不满意,于是扔掉所有旧博文,开始重新捣鼓一个新的博客出来,最后再配上一个光彩华丽的开篇。上一次写开篇大概是一年前,那时捣鼓了个Octopress,放在DigitalOcean上,写了个开篇,然后,然后就到今天了。

不确定这是不是真的和星座有关,我对博客的界面要求甚高,比如这次,开始想重新写其实是在一两月前,但因为一直没有找到满意的博客程序和皮肤而拖到了现在,不过,目前我对这个新博客的皮肤还是相当满意的,在Retina下显示效果非常好,逼格迅速提升(暂时还没为Windows做调整)。

但是,这种折腾真的真的应该结束了…

写博客还是应该回归到专注于内容本身,所以希望这是最后一次捣鼓博客程序,也是最后一次写开篇。