Scala类型系统的目的——Martin Odersky访谈(三)
Martin Odersky向Frank Sommers和Bill Venners谈论Scala类型系统背后的设计动机。
\\
Scala是一种新兴的通用用途、类型安全的Java平台语言,结合了面向对象和函数式编程。它是洛桑联邦理工大学教授Martin Odersky的心血结晶。本访谈系列由多部分组成,由Artima网站的Frank Sommers和Bill Venners向Martin Odersky讨教Scala。在第一部分Scala起源中(点击查看《Scala起源》中文翻译),Odersky讲述了导致Scala诞生的那些历史。在第二部分Scala的设计目标中,Odersky讨论Scala设计中的妥协、目标、创新和优势。在本期中,他将挖掘Scala的类型系统的设计动机。
\\
Scala的“可伸缩性(scalability)”价值
\\
Frank Sommers: 去年JavaOne大会上,你声称Scala是一种“可伸缩的语言”,既可以用于小规模程序,也可以用于大规模程序。像我这样的程序员,使用类似这样的语言,有什么好处?
\\
Martin Odersky: Scala带来的帮助,就是让你不必混用多种专用语言。无论小型程序还是大型程序,无论通用用途还是特定应用领域,你都可以只用这一种语言。这意味着,你不用操心如何在不同语言环境中传递数据。
\\
如果你想要跨越边界传递数据,按现在的业界惯例,你往往会陷入底层实现的重重泥潭中。比如,如果你想用JDBC,从Java向数据库发起一次SQL查询,那么你发出的查询最终会是个字符串。这就意味着你的程序中只要有小小的拼写错误,在最终运行时,就会表现为一次非法查询,很可能就导致最终客户网站出错。整个过程中编译器或类型系统并不会告诉你,你某处写错了。这非常脆弱和危险。所以,如果你只用一种语言,会有很多好处。
\\
另一个问题是工具。如果您只使用一种语言,那么你只需要面对一套环境和工具。而如果你有多种不同的语言,你就必须混合并适配多套环境,构建过程也变得更复杂、更困难。
\\
Scala的可扩展性(extensibility)
\\
Frank Sommers: 上次演讲你还提到了可扩展性。你说Scala很容易扩展。你能解释一下吗?同样再问一句,这对程序员有什么好处?
\\
Martin Odersky: 可伸缩性的维度是“从小到大”。除此之外,我觉得还有另一概念“可扩展性”,表示“从通用用途到特定需求”。你需要强化Scala语言,使之涵盖你特别关注的领域。
\\
举个例子,数字类型。不同领域有很多特殊的数字类型——比如,密码学家用的大数、商务人士用的十进制大数,科学家用的复数——这样的例子不胜枚举。上述每个群体都会真正深切关注他们所需的类型,但作为一门语言,如果加入了所有类型,就会过于笨重。
\\
怎么办呢?当然我们可以说,这样吧,把这些类型留给外部库实现吧。不过,如果你真的关心某个应用领域,那么你会希望,调用这些库的代码,看起来能像调用内置类型的代码一样干净、优雅。为此,你需要语言提供某些扩展机制,使你可以编写用起来感觉不像库的库。对库用户来说,比方说,使用某个十进制大数库中的BigDecimal类型时,应该像使用内置的Int一样方便。
\\
小规模编程中的类型
\\
Frank Sommers: 先前你提到,在使用单一语言而非混用多语言的场合,类型尤为重要。我觉得大部分人都认可,大规模编程时,类型确有其效。在超大型程序中,类型能帮你组织程序,保证改代码不会把程序搞坏。但是,小规模编程的场合下我们为什么还要用类型?比如只编写一段脚本时?对这种程度的编程,类型重要吗?
\\
Martin Odersky: 小规模程序恐怕类型真没那么重要。类型的价值分布在一条长长的光谱上,一端表示超级有用,一端表示极度麻烦。通常情况下,说它麻烦,是因为类型定义可能会太过冗余,要求你手动指定大量类型。说它有用,则是因为,类型能避免运行时错误,类型能提供API签名文档,类型能为代码重构提供安全底线。
\\
Scala的类型推断,试图尽可能减少类型的麻烦之处。这意味着,你编写脚本时并不需要涉及类型。因为即使你不指名类型,系统仍会为你推断出类型。同时,编译器内部仍然会考虑类型。所以你写的脚本如果有类型错误,编译器将发现错误,为你提供错误信息。而且,我相信,不管脚本还是大型系统,依靠编译器提示及早修复错误,总比推后错误要好。
\\
单元测试和随心所欲的表达式
\\
您仍然需要单元测试来测试你的程序逻辑。但相比动态类型语言,你不需要那么多针对类型的琐碎单元测试。根据很多人的经验,Scala所需的单元测试比动态语言少得多。你的经历可能有所不同,但我们在大量案例中所得体验的确如此。
\\
另一条针对静态类型系统的反对意见是:静态类型系统对表达方式限制太严。人们说,“我想自由地表达自己。我不想要静态类型系统的条条框框”。根据我的Scala经验,这种意见不靠谱,我认为有两个原因。第一个原因是,Scala的类型系统实际上非常灵活,所以通常它可以让你用非常灵活的模式排列组合。反之,像Java这样的语言,类型系统表达能力偏弱,就难以实现。第二个原因是,通过模式匹配,你可以通过非常灵活的方式抽回类型信息,甚至根本感觉不到类型信息的损失。
\\
Scala模式匹配的亮点在于,我可以对我一无所知的对象,用类似switch语句的结构,提供若干模式,进行匹配。只要这些模式之一匹配成功,我还能够立刻取出其中的字段,存到局部变量上。模式匹配是Scala核心中内置的结构。许多Scala程序都用了它。这属于用Scala干活的日常惯例。模式匹配还有个有趣的功能:自动抽回类型。当对你不知道类型的对象进行模式匹配时,如果匹配成功,实际上模式本身其实就可以提供一些类型信息。而类型系统可以利用这些信息。
\\
有了模式匹配,你可以很容易拥有一套系统,在系统中,你只使用通用类型(甚至通用到了极致,所有变量都是Object),但你仍然可以靠使用模式匹配获得所有类型信息。因此,在这个意义上,在Scala程序中,你可以像动态类型语言一样编写完美的动态代码。你可以到处都用Object,只要到处都模式匹配即可。现在一般人不这样做,是为了更好的利用静态类型的优势。但这种做法算是一种非常平滑的备用方案,平滑到了不知不觉的程度。相比之下,在Java中,类似情况下,你必须使用大量的类型检测(instanceof)和类型转换,可谓是又笨又重。我当然完全理解人们为什么反对到处滥用这种写法。
\\
聒噪的鸭子
\\
Bill Venners: 我观察到一件有关Scala的事情,相比Java的类型系统,在Scala类型系统中,我可以让程序表达出更多东西。从Java逃向动态语言的人往往会解释说,他们对类型系统感到沮丧,扔掉静态类型后他们感觉更好了。然而Scala似乎给出了另一个答案:尝试去改善类型系统,让它用途更广,用着更爽。哪些事情是在Scala类型系统能做到但在Java类型系统中却做不到?
\\
Martin Odersky: 针对Java类型系统有一条反对意见:缺了所谓的鸭子类型。可以这样解释鸭子类型:“如果它走起来像鸭子,叫起来像鸭子,那么它就是鸭子。”翻译一下:只要它具备我所需的功能,那么就可以把它当真。举例来说,我希望取得某种支持关闭操作的资源。我希望以这种方式声明:“它必须有close方法。”我不在乎它是File、Channel还是其他任何东西。
\\
要想在Java中实现的话,你需要定义一个包含该方法的通用接口,而大家都需要实现这个接口。首先,为了实现这一切,将导致大量接口和大量样板代码。其次,如果有了既成事实之后,你才想到要提个接口,那么几乎就不可能做到。如果事先就写了一些类,鉴于这些类早已存在,那么你不改代码就没法加入新接口,除非修改所有客户代码。所以,这一切困难都是类型强加给你的限制。
\\
另一方面,Scala比Java表现力更强。Scala能表达鸭子类型。你完全可以用Scala把某一类型定义为:“任何拥有close方法且close返回Unit(类似Java的void)的类型”。你还可以把类型定义与其他约束结合起来。你可以这样定义类型:任何继承自某个类型,且拥有某某方法签名的类型。你还可以这样定义:任何继承自某某类,且拥有某某类型的内部类的类型。从本质上讲,你可以通过定义类型中有哪些东西将为你所用,来描绘类型的结构。
\\
既存类型(existential types)
\\
Bill Venners: 既存类型加入Scala的时间比较晚。我听说Scala加入既存类型的理由是,为了把所有Java类型映射到Scala中。具体到既存类型,可以对应到Java的通配符类型。既存类型是否比通配符更强大?是不是Java通配符类型的超集?还有哪些理由要告诉大家?
\\
Martin Odersky: 不好说。因为大家对通配符并没有真正靠谱的构想。原先的通配符由Atsushi Igarashi和Mirko Viroli设计。他们的灵感来自既存类型。实际上原先的论文中的确包含了既存类型的字节码编码方案。但后来当实际最终设计加进Java时,二者的联系有所削弱。所以,我们也不知道通配符类型的确切现状了。
\\
既存类型早已发明,有着距今约20年的历史。既存类型要表达的概念很简单。它表示,给定某种类型,比如List,但其内部元素是未知类型。你只知道它是由某种特定类型元素组成的List,然而你并不知道元素的“特定类型”具体是哪种类型。在Scala中,这种概念可以用既存类型来表达。语法如下:List[T] forSome { type T }。稍微有点笨重。笨重的语法其实算是有意为之。因为事实证明,既存类型往往不大好处理。Scala有更好的选择。Scala不是那么需要既存类型,因为Scala的类型可以包含其它类型作为内部成员。
\\
归根结底,Scala只有三种情况需要既存类型。第一,Scala需要能表示Java通配符的语义。既存类型能提供这种语义。第二,Scala需要能表示Java的raw类型,因为有许多库使用了非泛型类型,会出现raw类型。如果有一个Java raw类型(如 java.util.List),那么它其实是未知元素类型的List。它也可以用Scala中的既存类型表示。第三,既存类型可以用来把虚拟机中的实现细节反映到上层。类似Java,Scala使用的泛型模型是“擦除模型”。所以在程序运行起来以后,我们就再也找不着类型参数了。之所以要进行擦除,是为了与Java的对象模型可以互相操作。可是,如果我们需要反射,或者要表达虚拟机的实现细节时,怎么办?我们需要有能力用Scala中的某些类型表示Java虚拟机的行为。既存类型就提供了这种能力。有了既存类型,即使某一类型中的某些方面尚不可知,我们仍然可以操作该类型。
\\
Bill Venners: 你能举个具体例子吗?
\\
Martin Odersky: 以Scala的List为例。我希望能够描述head方法的返回类型 。该方法返回List的第一个元素(即首元素)。在虚拟机级别,List类型是List[T] forSome { type T }。我们不知道T是什么,只知道head返回T 。既存类型理论告诉我们,该类型是“某些类型T中的T”,也就相当于根类型, Object 。那么我们从head方法取回的就是Object。因此,在Scala中,要是我们知道更多信息,我们可以直接指定具体类型而不用既存类型的限定规则。但要是没有更多信息,我们就留着既存类型,让既存类型理论帮我们推断出返回类型。
\\
Bill Venners: 如果当初你不需要考虑Java兼容性,比如通配符类型、raw类型和类型擦除,那么你还会加入既存类型吗?如果Java采用的是具现化的泛型类型系统,不支持raw类型或通配符类型,那么Scala还会有既存类型吗?
\\
Martin Odersky: 如果Java采用的是具现化的泛型类型系统,不支持raw类型或通配符类型,那么我觉得既存类型用处不大,恐怕Scala中不会加入。
\\
Java和Scala中的型变(Variance)
\\
Bill Venners: Scala中的型变定义位于类型定义之处,而Java的型变则要定义在使用通配符的代码之处。你能否谈谈二者的差异?
\\
Martin Odersky: Scala的既存类型一样支持通配符,所以,只要你愿意,你照样可以在Scala中使用与Java相同的写法。但是,我们建议你不要这样做,我们鼓励你改用位于类型定义之处的型变语法。为什么呢?首先,什么是“类型定义之处的型变”?当你定义某个类的类型参数时,例如List[T]时,会有一个问题。如果给你一个“苹果(Apple)列表”,那么,它算不算“水果(Fruit)列表”呢?你会说,当然算。只要Apple是Fruit的子类型, List[Apple]就应该是List[Fruit]子类型。这种子类型的关系称为协变(covariance) 。但在某些情况下,这种关系不成立。比方说,我有一个变量,只能用来保存Apple,那么这个变量就是对类型Apple的引用。这个变量并不能当做Fruit类型的引用 ,因为我并不能把任意Fruit赋值给这个变量。它只能是Apple。所以,你可以看到,上述的子类型关系,在有一些情况下适用,另一些情况下不适用。
\\
Scala中的解决方案是,给类型参数加个标志。如果List中的T支持协变 ,我们可以写做List[+T]。这将意味着任意List之间的关系都可以随着其T的关系而协变。要想支持协变,必须遵守协变的附加条件。举例来说,只有List内容不可变时,List才能支持协变,因为若非如此,就会遇上刚才引用变量例子中类似的问题,而导致无法协变。
\\
Scala中的机制是这样的:程序员可以声明“我觉得List应该支持协变”,即,要求List必须遵守子类型关系。那么,程序员把在类型声明之处,给类型参数T标上加号——只标注一次。而List的任何用户,都只需直接使用即可。然后,编译器会去找出List内的所有定义实际上是否兼容于协变,确保List中不存在导致冲突的成员签名。如果Scala编译器发现了不兼容协变之处,就触发编译错误。Scala社区有一系列的惯用技术来解决这些错误。称职的Scala程序员通常可以很快掌握这些技术。当称职的Scala程序员用上这些技术时,只要他编写的类最终通过了编译,就能为用户提供协变性。而用户就不再需要考虑协变问题了。用户只知道,只要给定一个List,就能以协变方式到处使用了。因此,这意味着,仅仅只有编写List类的那一个人必须多思考一点。但这其实不算太难,因为编译器会输出错误信息来帮助他。
\\
相比之下,以Java的方式使用通配符,这就意味着库的提供者对协变爱莫能助,只能草草定义成List\u0026lt;T\u0026gt;了事。但接下来如果用户需要协变List,却不能写做List\u0026lt;Fruit\u0026gt;而必须写做List\u0026lt;? extends Fruit\u0026gt;。通配符就是这样用的。问题在于,这是用户代码啊!用户总不可能人人都像设计库的人那么专业吧。此外,这些标注之间,只要有一处不匹配,就会导致类型错误。就算通配符搞出了海量极晦涩的错误信息,那也毫不称奇。我觉得这是Java泛型为人诟病的首要原因了。因为,通配符用起来实在是相当复杂,正常人类根本无从把握、无法处理。
\\
当我们结合泛型和子类型时,型变是个核心功能。然而它也很复杂。并没有什么办法可以完全化解其复杂度。我们能比Java做得好点,就在于,我们让你可以在库中处理型变,使用户感觉不到型变存在,不必手动处理型变。
\\
抽象类型成员
\\
Bill Venners: 在Scala中,一个类型可以是另一种类型的内部成员,正如方法和字段可以是类型的内部成员。而且,Scala中的这些类型成员可以是抽象成员,就像Java方法那样抽象。那么抽象类型成员和泛型参数不就成了重复功能吗?为什么Scala两个功能都支持?抽象类型,相比泛型,能额外给你们带来什么好处?
\\
Martin Odersky: 抽象类型,相比泛型,的确有些额外好处。不过还是让我先说几句通用原理吧。对于抽象,业界一直有两套不同机制:参数化和抽象成员。Java也一样支持两套抽象,只不过Java的两套抽象取决于对什么进行抽象。Java支持抽象方法,但不支持把方法作为参数;Java不支持抽象字段,但支持把值作为参数;Java不支持抽象类型成员,但支持把类型作为参数。所以,在Java中,三者都可以抽象。但是对三者进行抽象时,原理有所区别。所以,你可以批判Java,三者区别太过武断。
\\
我们在Scala中,试图把这些抽象支持得更完备、更正交。我们决定对上述三类成员都采用相同的构造原理。所以,你既可以使用抽象字段,也可以使用值参数;既可以把方法(即“函数”)作为参数,也可以声明抽象方法;既可以指定类型参数也可以声明抽象类型。总之,我们找到了三者的统一概念,可以按某一类成员的相同用法来使用另一类成员。至少在原则上,我们可以用同一种面向对象抽象成员的形式,表达全部三类参数。因此,在某种意义上可以说Scala是一种更正交、更完备的语言。
\\
现在的问题来了,这对你有什么好处?具体到抽象类型,能带来的好处是,它能很好地处理我们先前谈到的协变问题。举个老生常谈的例子:动物和食物的问题。这道题是这样的:从前有个Animal类,其中有个eat方法,可以用来吃东西。问题是,如果从Animal派生出一个类,比如Cow,那么就只能吃某一种食物,比如Grass。Cow不可以吃Fish之类的其他食物。你希望有办法可以声明,Cow拥有一个eat方法,且该方法只能用来吃Grass,而不能吃其他东西。实际上,这个需求在Java中实现不了,因为你最终一定会构造出有矛盾的情形,类似我先前讲过的把Fruit赋值给Apple一样。
\\
请问你该怎么做?Scala的答案是,在Animal类中增加一个抽象类型成员。比方说,Scala版的Animal类内部可以声明一个SuitableFood类型,但不定义它具体是什么。那么这就是抽象类型。你不给出类型实现,直接让Animal的eat方法吃下SuitableFood即可。然后,在Cow中声明:“好!这是一只Cow,派生自Animal。对Cow来说,其SuitableFood是Grass。”所以,抽象类型提供了一种机制:先在父类中声明未知类型,稍后再在子类中填上某种已知类型。
\\
现在你可能会说,哎呀,我用参数也可以实现同样功能。确实可以。你可以给Animal增加参数,表示它能吃的食物。但实践中,当你需要支持许多不同功能时,就会导致参数爆炸。而且通常情况下,更要命的问题是,参数的边界。在1998年的ECOOP(欧洲面向对象编程会议)上,我和Kim Bruce、Phil Wadler发表了一篇论文。我们证明,当你线性增加未知概念数量时,一般来说程序规模会呈二次方增长。所以,我们有了很好的理由不用参数而用抽象成员,即为了避免二次方级别的代码膨胀。
\\
用惯Scala的语法
\\
Bill Venners: 大家随便翻些Scala代码来读时,会有两件事情,让大家觉得Scala有点隐晦。首先,可能会遇上某种不熟悉的DSL(领域特定语言),比如Scala的parser combinators库或是XML库。其次,可能会遇上类型系统中的各种怪符号,尤其当这些怪符号一起出现时。Scala程序员怎么才能找到处理这类语法的诀窍呢。
\\
Martin Odersky: 当然,需要学习和吸收的新东西不少。因此,这需要一定的时间。我相信有一件事我们必须努力:更好的工具支持。目前,如果你触发了了类型错误,我们尽量给你提供友好的错误信息。有时,为了能解释为何出错,错误信息会横跨多行。我们一直在努力,但我觉得我们还可以做得更好:我们可以增加错误信息的交互性。
\\
试想一下,假如你用动态类型语言时发生了运行时错误,每条错误信息最多三四行。而且又没有调试器、没有调用栈信息,就只有三四行的“未将对象引用设置到对象的实例”,可能最多再给你一个行号。那么这种情况下,我觉得动态类型语言不可能流行起来。当然啦,现实中的动态类型语言没这么弱,它会把你扔到调试器中,让你可以快速找出错误根源。
\\
我们的类型系统目前还做不到。我们只能得到这点错误信息。Scala的类型系统非常丰富、表达力强,需要更多知识才能让错误信息靠谱,程序员就会需要更多工具的协助。所以,我们今后会调研一件事,我们能不能真正提供更具交互性的环境,让程序员能在类型系统出错时找出错误原因。例如,如何让编译器能找出表达式的类型、能知道为什么实际类型与所需类型没匹配上。我们可以通过交互方式挖出这些信息。我想,到了那时,程序员就可以比现在更容易找到类型错误的原因了。
\\
另一方面,一些语法还很新,需要一些时间适应。这一点我们可能无法回避。我们只希望若干年后,这些语法能被大家不假思索、完全自然的接受。目前主流语言中的一些东西,当初大家接受时,也花了不少时间。我清楚的记得,异常刚出现时,大家也觉得很奇怪,花了很多时间才适应。而到了现在,每个人都觉得异常用起来相当自然了,不再觉得新奇。Scala确实引入了一些新东西(主要是在类型方面),这些新东西需要一些时间来适应。
\\
查看英文原文:The Purpose of Scala's Type System
\\
感谢魏星对本文的审校。
\\
给InfoQ中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ,@丁晓昀),微信(微信号:InfoQChina)关注我们,并与我们的编辑和其他读者朋友交流(欢迎加入InfoQ读者交流群)。
Scala类型系统的目的——Martin Odersky访谈(三)相关推荐
- Scala模式匹配的亮点——Martin Odersky访谈(四)
Martin Odersky向Bill Venners和Frank Sommers谈论Scala模式匹配的机制和目的. \\ Scala是一种新兴的通用用途.类型安全的Java平台语言,结合了面向对象 ...
- Scala的设计目标——Martin Odersky访谈(二)
Scala是一种新兴的通用用途.类型安全的Java平台语言,结合了面向对象和函数式编程.它是洛桑联邦理工大学教授Martin Odersky的心血结晶.本访谈系列由多部分组成,由Artima网站的Fr ...
- Martin Odersky Scala编程公开课 第三周作业
Functional Programming Principles in Scala by Martin Odersky 这次的作业叫做Object-Oriented Sets.要完成一个完整的类, ...
- Martin Odersky Scala编程公开课 第二周作业
Functional Programming Principles in Scala by Martin Odersky 这一周的主要内容是函数.函数是scala语言最重要的概念,既可以当作函数的参 ...
- Martin Odersky Scala编程公开课 第一周作业
Functional Programming Principles in Scala by Martin Odersky Martin教授是scala语言的creator,在coursera上面有s ...
- She Knows访谈 | 三大公链创始人技术硬核交锋,2019年浪潮将由什么引爆?
She Knows第三期对话嘉宾:本体(Ontology)创始人李俊网录科技创始人.万维链(Wanchain)发起人吕旭军Nervos基金会联合创始人吕国宁. 对话人:巴比特内容总监王晓萌,资深媒体人 ...
- Scala类型系统——高级类类型(higher-kinded types)
高级类类型就是使用其他类型构造成为一个新的类型,因此也称为 类型构造器(type constructors).它的语法和高阶函数(higher-order functions)相似,高阶函数就是将其它 ...
- 技术人攻略访谈三十七-程显峰:IT病得有多重?技术圈交际花谈研发管理怪现状
文:Gracia (本文为原创内容,部分或全文转载均需经过作者授权,并保留完整的作者信息和技术人攻略介绍.) 导语:本期采访对象程显峰@程显峰-Mars,蓝海讯通COO.素有"技术圈交际花兼 ...
- 技术人攻略访谈三十八-许式伟:十一年逆流顺流,首席架构师到CEO
文:Gracia (本文为原创内容,部分或全文转载均需经过作者授权,并保留完整的作者信息和技术人攻略介绍.) 导语:本期访谈对象@许式伟,七牛云存储CEO,国内Go语言圈领军人物,ECUG社区发起人. ...
最新文章
- 网站性能越来越差怎么办?
- Python网络爬虫 - 一个简单的爬虫例子
- Bio+IT 爱好者社区,欢迎你!
- 系统服务器巡查表,服务器操作系统巡检表
- 外贸网站建设需要考虑的五大层面
- 机器学习-损失函数 (转)
- mysql事务 可见性,【每日阅读】2020年12月09日-事务先后的可见性
- [剑指offer][JAVA]面试题第[16]题[数值的整数次方][位运算][二分法]
- LeetCode 旋转数组 系列
- 安装kenlm出现问题的解决方案gcc g++
- 【算法】剑指 Offer 45. 把数组排成最小的数 【重刷】
- python.集合转列表_python 列表,元组,字典,集合,字符串相互转换
- Java序列化机制和原理
- 配置百度云CDN加速
- cocos creator 打包apk_Cocos Creator Android打包 apk
- Ubuntu系统文件被上锁了怎么编辑:Ubuntu系统获得读写权限
- 重启计算机可以使用什么组合键,死机重启电脑快捷键有哪些
- Typora中写论文怎么添加reference(参考文献)
- 如何用计算机校验信息,Win10如何校验文件哈希值(系统自带方法)?
- 单片空间后方交会 python实现
热门文章
- mxnet img2rec的使用,生成数据文件
- c# picturebox控件显示本地图片和显示网上的图片
- 分享几个益智题......看你能做对吗?
- python 多个列表合并_Python实现合并两个列表的方法分析
- AOP五大通知注解详解
- 无需担心架构演变 入云的Teradata无处不在
- Web表单美化CSS框架Topcoat
- Ubuntu安装过程中的问题
- Oracle在JavaOne上宣布Java EE 8将会延期至2017年底
- 有了bootstrap,为什么还要做amaze ui