译序

初学一样东西容易拿了一片树叶,却掩了整座泰山。面向对象编程是现在业界事实上的主流。自打接触面向对象编程那天起,就不断的见到或听到绝大多数人对它的溢美;言必曰对象,行定从OO。更有面向对象的狂热分子甚至总结归纳出了设计模式云云。我也曾奉面向对象为神圣无敌先进的编程思想,把封装继承多态作为编程的奥义去领会。但是实践中我渐渐发觉面向对象并不如传颂的那般美好,在网上书上也接触了零星的和面向对象唱反调的说法。而且最近几年主流的面向对象语言不约而同的融入了诸如函数式编程范式特征的要素。是面向对象发展到了瓶颈需要返璞归真了吗,还是我压根就歪解了它?我倾向于反对将面向对象当成唯一哲学,封装继承多态也不是面向对象的真正精髓所在;但我只是感觉面向对象在很多时候捉襟见肘,对现在反面向对象的说法并无太多切身的领悟,仅是听上去有点道理。于是我想,去了解一下面向对象产生、发展、被歪曲、被谬解的历史,尝试看看用不同的面向对象语言(如Simula、Smalltalk、Objective-C)写出的程序是什么样子,并希望以此给自己一个说法。
Smalltalk是上世纪70年代产生的早期面向对象语言,确切的说是第二款。很多当今主流面向对象语言源出于此。Smalltalk作者本人曾说他想主张的面向对象思想重点不在类而是消息传递。以Smalltalk入手了解面向对象的历史很适合。这篇文章是我搜到的比较合适的Smalltalk入门文章,国内有片断译文但成熟度不高。恕我斗胆翻译公开于此。以前只是阅读外文资料从没翻译并写出,翻完此文发觉字斟句酌的翻译正是学习外文资料的好方法。
如有谬误,敬请指正。

英文原版
http://live.exept.de/doc/miscDocuments/readingSmalltalk.pdf

Wilf LaLonde
Journal of Object-Oriented Programming, February 2000, pp. 40-45, http://www.joopmag.com

简介
我听很多人和我说他们擅长C++或Java但是完全搞不懂Smalltalk。按他们的说法Smalltalk有若天书!我想了一下,觉得他们说的或许非常在理。假如我只懂Java,如果我从多年以来写的代码里随便挑一段我肯定看不懂它。在理解Smalltalk之前必须要澄清一些很简单的概念连带一些细微诡异的语法概念。要是“老王不懂Smalltalk”,也许我能对他的状况进行改善。我希望能让读者快速上手。我假设读者懂面向对象编程。如果你已经会Smalltalk了就请恕我班门弄斧一下。

语法细节真简单
初读 Smalltalk遇见的一些协定和惯用法细节可能与其它语言大相径庭从而把你搞晕,像双引号括注释,单引号括字符串,还有字符的特殊语法表示(例:$x代表“x”)。还有symbol的概念,symbol是在内存中仅有唯一实例的字符串;例如,当一个symbol被构造时(通常是编译期),先从内存里查找是否相同的实例,如果有则直接使用。这样做目的不是节省内存而是优化比较效率(下文详述):
"this is a comment"
'this is a string'
#'this is a symbol'
#thisIsASymbolToo
赋值和比较运算符有细微差别:
:= // 赋值
=  // 内容相等比较,深比较
== // 唯一性比较,浅比较
如果你给我两个被不同变量“a”和“b”引用的不同对象,我就能告诉你它们是不是相同对象(通过a == b进行)或者只是看起来相同的不同对象(通过a = b进行)。直白的说,==比较两个指针而=比较对象的整个内容。
Smalltalk中很少出现逗号,因为它不充当语法要素。这就是为什么数组直接明了,例如下面没有冗余逗号的数组:
#(1 2 3 4 5)
尽管如此逗号还是有意义的,它是一个运算符。你偶尔能看到它被用来连接两个字符串,例如:
'string1','string2'

关键字无孔不入
在Smalltalk中关键字无处不在。但它们有益于可读性而不是扰乱。想知道为什么,让我们从一个C++和Java片断入手。例如你可能对下面的写法再熟悉不过了:
t->rotate(a, v);  // C++
t.rotate(a, v);   // Java
t对象被夹带着参数a和v发送了rotate消息。读者想理解这样的代码通常需要找到变量的声明处并判断出类型。我们假定声明如下:
Transformation t;
float a;
Vector v;
在Smalltalk中变量可以引用任意类型的对象。所以类型说明不需要,但是我们还是要声明一下变量,例如:
|t a v|
在不看声明的情况下,好的Smalltalk程序员会通过变量顾名思义判断其类型。那么我们换一种写法如下:
|aTransformation angle aVector|
但请允许我继续沿用最初的短命名来避免示例代码太长影响阅读。我们来通过去除不必要的因素来“改进”C++和Java的语法。例如,下面的代码仍然明了:
t.rotate(a, v); // 原始写法
t rotate(a, v); // 谁需要点号?
t rotate a, v;  // 谁需要括号?
为了进一步改进语法我们需要知道参数a和v代表什么。我们假定整个示例意为“绕向量v旋转角度a(译注:rotate by angle a around vector v)”。则进一步改进为:
t rotate by a around v; // 谁需要逗号?
我们能明确每个成分是什么吗?没问题,因为在我们改进的这个示例中,“t”是一个变量,“rotate”是方法名,“by”是分隔符,“a”是变量,“around”是分隔符,最后的“v”也是一个变量。为了消除潜在歧义我们设立一个规定:分隔符后面紧跟一个冒号。我们得到:
t rotate by: a around: v; // 谁需要模棱两可?
最后我们强调一下分隔符是方法名的一部分;例如我们假定需要一个形如“rotate by: around:”的函数,去掉空格我们得到“rotateby: around”作为最终命名,再将非首单词首字母大写来提高可读性得到“rotateBy: around”。那么我们的示例可以写为:
t rotateBy: a around: v // 这才是Smalltalk
方法名被打碎成几部分。幸运的是聚拢这些碎片成一个完整的名字很容易。当在类中时我们如下定义方法名:
self rotateBy: angle around: vector
    |result|
    result := COMPUTE ANSWER.
    ^result
在运行时,“t”和“self”,“a”和“angle”,“v”和“vector”之间有着一对一的关系。注意“^”意味着结果被返回了;这是 Smalltalk中“return”关键字的写法。变量“self”是“this”的同意字,如果方法结束没有返回语句则“^self”被当作隐含语句执行;你可能完结一个方法时忘记添加返回语句,但没事。这也意味着即使消息发送者不需要返回值,方法也会返回它。
实际上被惯用的地道Smalltalk语法要求“self”不显式的出现在方法头(但必隐含),例如:
rotateBy: angle around: vector
    |result|
    result := COMPUTE ANSWER.
    ^result
关键字语法的精妙之处在于我们可以为不同的方法定义不同的关键字。例如我们可以如下定义第二个方法:
t rotateAround: vector by: angle
不必死记硬背参数顺序。关键字提示我们顺序。当然程序员有滥用关键字的能力,例如如果我们如下定义关键字:
t rotate: angle and: vector
读者很难弄清参数正确的顺序。这就是个极差的编程风格,如果只有一个参数还好办。只有一个参数时我们仍然需要方法名;例如:
t rotateAroundXBy: angle
t rotateAroundYBy: angle
我们希望关键字(因冒号而易区分)成为参数的说明。但方法没有参数时怎么办:
t makeIdentity: // 结尾的冒号有意义吗?
如果关键字代表参数的说明,那我们在没有参数的情况就用不到关键字。所以零参数的消息应为:
t makeIdentity // 这才是Smalltalk
当然二元操作符同理,但一元操作符(makeIdentity是一元消息但不是一元操作符)并非如此。当多种消息一起出现时我们的表达式也许形如:
a negative | (b between: c and: d)
    ifTrue: [a := c negated]
作为读者应该知道“a”被发送了一个返回true或者false的名为“negative”(零参数)的消息;“b”也被发送了一个返回true或者 false的为“between: c and: d”的消息。两项的结果or到一起成为消息“ifTrue: [a := c negated]”的接收者。这就是if-then控制结构的地道写法而不是特殊语法。仅是以布尔值作为接收者,以“ifTrue”作为关键字,并且以“[a := c negated]”(我们称其block)作为参数的标准关键字语法。在Smalltalk中你永远遇不到“a := -c”因为不存在一元操作符,但你会看到“-5”这种常量,“-”在此充当常量的一部分。
所以如果你看到形如“-3.5 negated truncated factorial”的表达式时应该立即意识到这其中没有关键字。所以“-3.5”必定是被发送了“negated”消息;执行结果3.5被发送了“truncated”消息;然后执行结果3被发送了“factorial”,产生最终结果6。
当然,还有诸如运算优先级的规则(从左到右),消息优先级(零参数最高,二元运算次之,最后关键字)。写代码时这些很重要,但读代码不必刻意在意这些细节。如下从左到右的表达式:
1 + 2 * 3 得 9
没有优先级,但是你很难遇到有Smalltalk程序员这么写表达式,因为这会迷惑非Smalltalk读者。一般Smalltalk程序员使用如下替代写法:
(1 + 2) * 3
即使括号是没必要的。

分号和句号不同
大多数非Smalltalk程序员把分号当成语句结束的标识,但在Smalltalk中使用句号表达此意。所以我们不会写:
account deposit: 100 dollars;
collection add: transformation;
而是写成:
account deposit: 100 dollars.
collection add: transformation.
嗯!“dollars” 消息迷惑你了吗?不要觉得不可思议。此处必有一个在Integer类中构造一个“Dollar”对象并返回它的名为“dollars”的方法。它不是 Smalltalk标准环境中的但是我们可以扩充它。基(内建)类可以在需要时像用户自定义类那样被扩充。
所以,句号是语句终止符号而且在最后一条语句中是可选的(如果你愿意,可以把它当成语句终止符)。但分号仍然是合法的特殊分隔符(不是终止符)。它被用来指定接收者是可缩略的。所以,如下写法:
|p|
p := Client new.
p name: 'Jack'.
p age: 32.
p address: 'Earth'.
可以写为:
|p|
p := Client new.
p
    name: 'Jack';
    age: 32;
    address: 'Earth'.
或者更好的写法:
|p|
p := Client new
    name: 'Jack';
    age: 32;
    address: 'Earth'.
Smalltalk对排版不敏感。我们甚至可以把所有语句放到同一行中。本质上讲分号指定前一个消息被发送用来修改接收者,并且下一个消息应当被发送给相同的接收者(而不是被发送到被忽略抛弃的运算结果)。
最后的例子中,“new”被发送给一个类以获得实例(运算结果)。然后“name: 'Jack'”被发送给那个实例。第一个分号指定“name: 'Jack'”的结果被忽略,“age: 32”应被发送给之前的接收者(相同的那个实例)。第二个分号指定“age: 32”的结果被忽略,“address: 'Earth'”应被发送给之前的接收者(仍然是那个相同的实例)。最后“address: 'Earth'”的运算结果被赋值给p。修改接收者的方法通常返回接收者本身,所以p被绑定到最近修改的Client实例上。
我们可以通过用英文词组“AND ALSO”取代分号来简化上面的赋值。即“new”被发送给类“Client”,并且结果实例被发送了“name: 'Jack'” AND ALSO “age: 32” AND ALSO “address: 'Earth'”消息。重复的向相同接收者发送不同的消息在Smalltalk中被称为层叠(译注:cascading)。分号也可以出现在子表达式中,如“p := (Client new name: 'Jack'; age: 32; address: 'Earth')”——注意圆括号。

Get/Set方法与变量实例同名
在Smalltalk中诸如name,age,address这样的Client类实例的成员变量都是private的。但可以通过实现一定的方法访问它们。例如在C++(Java类似)中,我们经常写出如下访问方法(通常被称为get/set方法):
long getAge() { return age; }
void setAge(long newAge) { age = newAge; }
如果你在大把的类上应用这种方式,你将写出成百个以get和set开头的消息。如果你偶然决定通过使用精简的命名来简化这些方法(稍后写出),即便Java编译器能做出正确识别,也会给C++编译器造成解析混乱,因为它无法区分变量和方法:
long age() { return age; }
void age(long newAge) { age = newAge; }
你能区分变量“age”和消息“age”吗?理应区分。当你使用消息时需要带上圆括号如“age()或age(22)”;当你使用变量时就不必带上圆括号。Smalltalk中的等价写法为:
age ^age
age: newAge age := newAge
我们通常使用如下分行写法来提高可读性:
age
    ^age
age: newAge
    age := newAge
在Smalltalk中你不必依赖圆括号就能轻松区分变量和消息。如果你对它们的区别很明了,就能看出下面的表达式有多少个变量:
age age age: age age + age age
嗯!答案是3个;第一个和第四个age必为变量(紧跟关键字的子表达式和所有表达式必以一个变量开头),第七个也必为变量(二元操作符后的子表达式也必以一个变量开头)。再看一个更明显的类似的典型例子:
name size // name必为变量
self name // name必为变量

广泛使用的集合
在Smalltalk中使用最普遍的两种集合分别是有序集合(ordered collection)和字典(dictionary)。数组的概念等效于大小不可变的有序集合。
|a b|
a := OrderedCollection new
    add: #red;
    add: #green;
    yourself.
b := Dictionary new
    at: #red put: #rouge;
    at: #green put: #vert;
    yourself.
上面的每个赋值中变量都被绑定到最后一条消息的执行结果上;例如“yourself”的结果就是最后一次创建的集合。“yourself”消息被设计成返回消息接收者(像个无运算操作)但“add:”和“at: put:”并非如此(它们返回最后一个参数)。所以如果没有“yourself”就成了“a”绑定到“#green”,“b”绑定到“#vert”。
我故意使用层叠写法来解释“yourself”为什么独立的出现在内建类的方法中。
Smalltalk中集合的优势是你可以存任意类型的对象进去。即使是字典中的键都可以使任意类型;同一集合中的对象也可以是不同类型。我们不必为了在一个实例中聚集一批新的类型而重新发明新的集合类型。
可以像访问数组一样访问有序集合;例如“a at: 1”索引到元素1。字典也能用相同的方式访问;例如“b at: #red”。但很多应用场合我们不必关心键。如此这般,元素迭代循环很容易:
a do: [:item |
    USE item FOR SOMETHING].
b do: [:item |
    USE item FOR SOMETHING].
即便集合中的元素是不同类型的,“item”变量也会一个接一个的获取到每一个元素。如果需要我们能在运行时知道一个对象是什么可以写成“item isKindOf: Boat”,它返回true或false。同时还有许多特殊类型查询消息,像“item isCollection”或“item isNumber”。更进一步还有很多创建新的集合的循环构造消息如:
c := clients select: [:client | client netWorth > 500000].
d := clients collect: [:client | client name].
上例中前者我们得到大款客户的集合。后者我们获得客户名字的集合(原始集合是一堆客户的集合)。

有序抽象无需新类的构建
读者经常看到如下代码:
stuff value: x value: y value: z
此处关键字全是“value:”。对一个非Smalltalk程序员来说这样写毫无意义混乱不堪。程序员在此已经(而且经常)创建新的抽象。
让我来解释一下仅有Smalltalk支持的特性。还以我们已经多次介绍的Client类为例,假设我们有一个遍历某个客户所有部分的简单需求;例如我们想先遍历到name,然后是age,最后是address。
C++ 和Java对此需求的惯例解决方案是创建一个新的特殊流化(stream)类或枚举器(enumerator)类,也许叫ClientIterator,它带有初始化、判断是否迭代结束、如果未结束迭代下一个对象的迭代器等方法。利用这些方法我们就能写一个循环初始化迭代器,获取下一个对象并处理它直到迭代结束。迭代器的优点是在顺序处理中它能提供一个单独的变量用于跟踪迭代到的位置;没必要把Client类展开成用于迭代的“临时”变量。
下面是一段刻意抽象的代码:
aClient := CODE TO GET ONE.
aClient partsDo: [:object |
    object printOn: Transcript]
注意partsDo:像一个以object为循环变量的循环。第一次遍历我们得到name并打印到transcript(一个Smalltalk编程环境中特殊的工作区)。然后第二次遍历得到age,最后第三次遍历得到address。同样值得注意的是“partsDo:”是一个以“aClient”为接收者,以“[:object | object printOn: Transcript]”(一个block)为参数的关键字消息。
在深入之前我先给出Smalltalk的解决方案。然后我解释一下它的工作原理并给出更多的惯用法的例子。我们要做的就是给Client添加如下方法:
partsDo: aBlock
    aBlock value: self name.
    aBlock value: self age.
    aBlock value: self address.
要理解这段代码要先认清这些block是匿名函数。为了更好的理解我所讲,请设想我们想把一个函数赋值给一个变量但是不调用它。我来写出它的类C语法风格写法(我知道用C语法做这件事的确切写法,不过那对阐述关键思想没有什么帮助;所以我就不写严格的C语法了):
a = f(x) { return x + 1; } // 类C风格语法
a := [:x | x + 1] // Smalltalk语法
这里变量“a”成了一个函数对象。f是一个函数,因此我们可以通过“f(10)”调用它得到11。但我们还能通用执行“a(10)”调用它因为a的值是一个函数。通过变量“a”执行函数不需要知晓和它原始名相关的信息。
所以在Smalltalk中我们甚至不纠结于函数的名字。我们可以轻易的把它赋给任意一个变量并通用此变量使用它。在上面简单演示函数调用的例子中,我们设定“a value: 10”使它返回11。在执行过程中,参数x被绑定为10,参与x + 1运算,一直执行到block末时最终计算出的结果被返回。
通常我们极少直接执行block。取而代之的我们写成形如“partsDo:”,隐藏笨拙的block“调用”来提供抽象功能。
来看更多的例子。假设我们有一个维护一个旅客链表的Airplane类。我们尝试一下遍历访问旅客中的所有儿童(假设定义12岁及以下为儿童)。实现此功能的代码如下:
anAirplane passengers do: [: person |
    person age <= 12
        ifTrue: [.. DO SOMETHING with person ..]]
如果我们需要在其它上下文中遍历访问儿童,稍作抽象会很有助于简化代码。我们所需要做的就是在Airplane类中实现一个名为“kidsDo:”的抽象(为便于引用说明,我为代码加上了行号):
1. kidsDo: aBlock
2.    "此处self是一个Airplane"
3.    self passengers do: [:person |
4.        person age <= 12
5.            ifTrue: [aBlock value: person]]
我们调整示例代码如下来表述抽象:
6. anAirplane kidsDo: [:kid |
7.    .. DO SOMETHING with kid ..].
8.    "完成。"
你能看出第6行代码是如何工作的吗?当第6行的“kidsDo: ...”消息执行时第一行的“kidsDo:”方法就被调用了。然后第1行的变量“aBlock”就被绑定了“[:kid | .. DO SOMETHING with kid ..]”(暂且称其kid block)。kidsDo:方法中第3行的“do:”会遍历所有旅客。第5行中aBlock仅在旅客年龄不高于12岁时才被发送一个“value:”消息。当以“person”为参数的“value:”消息执行时,就会引发一个对kid block的函数调用并导致“kid”绑定到“person”和第7行的“.. DO SOMETHING with kid ..”。执行到block末时执行流程从“kidsDo:”返回到“do:”循环(第5行末),然后继续如此处理其他kid。循环结束后执行流程从第6行进行的“kidsDo:”方法调用返回并到达第8行。
一言以蔽之,第6行的代码导致第1到第5行的代码循环执行,1到5行又会使kid block(第7行)执行。
总体上说,block为Smalltalk提供了一种最简洁的实现控制流抽象的手段。它也同样被精妙的设计用来执行语义上的返回语句,而且这是唯一可以表达此语义的方式。让我通过给Airplane添加一个和第6到8行类似的方法来阐述这个问题:
10. findAnySickKid
11.    "这里self也是一个AirPlane"
12.    self kidsDo: [:kid |
13.        kid isSick
14.            ifTrue: [^kid]].
15.    ^nil "不存在生病的"
通读代码,我相信你不会看出什么不寻常之处。这也是一个遍历飞机上所有儿童的循环。如果发现了一个生病的儿童就返回。另外,更进一步的迭代下去如果没有生病的儿童循环终止并返回nil(一个可被容易检测的特殊对象)。那这里值得注意的是什么呢?嗯有三点重要的地方:第10行findAnySickKid方法开始处,第1行kidsDo:方法开始处,还有最后第13、14行kid block。通过执行“anAirplane findAnySickKid”,先后调用了方法findAnySickKid,进而调用kidsDo:,进而kid block。在kid block里面执行“^kid”并不返回给发送者(kidsDo方法)而是返回给findAnySickKid的发送者。不管从kidsDo:到kid block内部的消息链多长,“^kid”始终从findAnySickKid返回。恕我孤陋寡闻还没听说过这个特性的称谓,我个人称其短路返回(译注:short circuit return)。

结论
我给没接触过Smalltalk的人开了个头。还有很多问题没有提及但问题不大。如果你是一个Smalltalk新手你现在应该能更容易的自己钻研了。非常感谢Alan Francis提醒我Java程序员可能像他自己那样对Smalltalk非常苦手。

我能读懂C++和Java但是读不懂Smalltalk相关推荐

  1. 终于,我读懂了所有Java集合——map篇(多线程)

    多线程环境下的问题 1.8中hashmap的确不会因为多线程put导致死循环(1.7代码中会这样子),但是依然有其他的弊端,比如数据丢失等等.因此多线程情况下还是建议使用ConcurrentHashM ...

  2. 终于,我读懂了所有Java集合——map篇

    首先,红黑树细节暂时撸不出来,所以没写,承诺年前一定写 HashMap (底层是数组+链表/红黑树,无序键值对集合,非线程安全) 基于哈希表实现,链地址法. loadFactor默认为0.75,thr ...

  3. 终于,我读懂了所有Java集合——queue篇

    Stack 基于Vector实现,支持LIFO. 类声明 public class Stack<E> extends Vector<E> {} push public E pu ...

  4. 终于,我读懂了所有Java集合——List篇

    ArrayList 基于数组实现,无容量的限制. 在执行插入元素时可能要扩容,在删除元素时并不会减小数组的容量,在查找元素时要遍历数组,对于非null的元素采取equals的方式寻找. 是非线程安全的 ...

  5. 一文读懂什么是Java中的自动拆装箱

    本文主要介绍Java中的自动拆箱与自动装箱的有关知识. 基本数据类型 基本类型,或者叫做内置类型,是Java中不同于类(Class)的特殊类型.它们是我们编程中使用最频繁的类型. Java是一种强类型 ...

  6. java integer valueof_一文读懂什么是Java中的自动拆装箱

    本文主要介绍Java中的自动拆箱与自动装箱的有关知识. 基本数据类型 基本类型,或者叫做内置类型,是Java中不同于类(Class)的特殊类型.它们是我们编程中使用最频繁的类型. Java是一种强类型 ...

  7. 终于,我读懂了所有Java集合——sort

    Collections.sort 事实上Collections.sort方法底层就是调用的Arrays.sort方法,而Arrays.sort使用了两种排序方法,快速排序和优化的归并排序. 快速排序主 ...

  8. 终于,我读懂了所有Java集合——set篇

    HashSet (底层是HashMap) Set不允许元素重复. 基于HashMap实现,无容量限制. 是非线程安全的. 成员变量 private transient HashMap<E,Obj ...

  9. 一文读懂计算机组成,一文读懂为什么要做动态心电图检查?

    什么是动态心电图? 动态心电图是一种可以长时间连续记录并编辑分析人体心脏在活动和安静状态下心电图变化的方法.此技术于1947年由Holter首先应用于监测心脏电活动的研究,所以又称Holter监测心电 ...

  10. 一篇读懂:Android手机如何通过USB接口与外设通信(附原理分析及方案选型)

    更多技术干货,欢迎扫码关注博主微信公众号:HowieXue,共同探讨软件知识经验,关注就有海量学习资料免费领哦: 目录 0背景 1.手机USB接口通信特点 1.1 使用方便 1.2 通用性强 1.3 ...

最新文章

  1. Camera开发系列之六-使用mina框架实现视频推流
  2. 三点钟群分享:全球虚拟礼物赠送平台项目落地经验
  3. 【转】Linux下c++调用自己编写的matlab函数:通过mcc动态链接库.so实现
  4. MySQL小工具推荐
  5. 学会c语言开发出很多,学会了C语言可以开发出很多东西吗?
  6. matlab安装详解
  7. 牛客网面试题总结(试券)
  8. 论嵌入式单片机软件架构
  9. 魔方阵原理及十种解法(C语言)
  10. 大物 磁场对载流导线的作用 中dl转化为dx
  11. PAT-A1008(C/C++代码解析)
  12. CSS——浮动的清除
  13. 机器视觉入门——VisionPro软件简介
  14. ibm mq 编程_IBM SOA编程模型简介
  15. NEX让人们对vivo刮目相看,这个互联网巨头出了一份力
  16. qbo机器人软件总体情况
  17. 剑网3指尖江湖快速升级攻略 悄悄抱走月儿
  18. 百度地图多个marker标点+点聚合
  19. 8.25关于笔试面试(数梦工场亲宝宝)
  20. TCP粘包、拆包与解决方案、C++ 实现

热门文章

  1. c语言事业单位考试试题,2020全国事业单位考试题目综合应用C类考情分析
  2. 游戏合作伙伴专题:BreederDAO 与 Metalcore 建立合作关系
  3. matlab mex dll,Visual Studio创建Matlab mex(dll)函数
  4. (C++字符串大小写转换)相似的句子
  5. JAVA计算机毕业设计在线药物配送系统Mybatis+源码+数据库+lw文档+系统+调试部署
  6. Python踩过的坑之PyQt5环境搭建
  7. Python读取及保存mat文件 注意事项
  8. 中国电影工业,走到了关键的十字路口!
  9. 【iPad密码】iPad忘记密码,不用电脑如何解锁?
  10. 【loadrunner使用篇】LoadRunner压力测试实例