有一个笑话说,计算机科学界有两大难题:一是缓存失效问题,二是命名问题。但我认为还有第三个更难的问题:相等问题。你没看错,等号“=”看似简单,但等号的使用和误用,是软件工程中许多重大问题的根源。

声明:本文已获作者Craig Stuntz翻译授权。

作者 | Craig Stuntz

译者 | 弯月,责编 | 郭芮

头图 | CSDN 下载自视觉中国

出品 | CSDN(ID:CSDNnews)

以下为译文:

相等的原理

我来展示一下在编程语言中相等有多么容易出错。但首先我要解释一下相等应有的模样,而这其实非常难解释!当我们讨论相等“应该如何”时,我们必须指出是特定语境下的相等,因为相等的成立有许多方式,许多方式在不同的语境下都是成立的。

“数学的精髓在于,同一个东西可以用不同的方式呈现给我们。”

——Barry Mazur,《When is one thing equal to some other thing》

定律

我说过,在不同的语境下,相等有不同的含义,但尽管如此,有些东西是永远成立的。这就是相等的定律。

相等是一个二元操作,它是:

  • 反射的,即对于任意值 a 都有 a = a

  • 对称的,即a = b可以推出b = a,反之亦然

  • 传递的,即如果a = b且b = c,则a = c

在编程的世界中,我们需要增加一条定律,因为有时候程序员会做一些奇怪的事情:

相等必须是:

  • 一致的,即如果a = b且a或b的任何字段都没有发生变化,那么稍后再次检查时a = b应当依然成立。

上面的定律看似简单,但流行的编程语言甚至连如此简单的定律都无法遵守。但更严重的关于相等的问题甚至都很难精确描述。

结构相等性

在编程语言对于相等的各种实现中,一个重要的区别就是结构相等和引用相等。

结构相等会检查两个引用是否为同一个值。F#默认采用了结构相等:

type MyString = { SomeField : string }
let  a = { SomeField = "Some value" }
let  b = { SomeField = "Some value" }
if a = b then // 返回true, 进入 "then" 代码块

但在C#中则不是这样,C#使用的是引用相等。引用相等要求两个被比较的对象是同一个对象。换句话说,它会比较两个变量是否指向同一个内存地址。指向两个不同内存地址的引用会被判为不相等,即使它们的值完全一样:

class MyString {
    private readonly string someField;
    public string SomeField { get; }
    public MyString(string someField) => this.someField = someField;
}
var a = new MyString("Some value");
var b = new MyString("Some value");
if (a == b) { // 返回 false, 不会进入代码块

其他语言会让你选择。例如,Scheme提供了equal?来检查结构相等,eq?来检查引用相等。Kotlin提供了==用于结构相等,===用于引用相等(不要与JavaScript的==和===操作符混淆,JavaScript的那个是完全不同的东西)。

程序中何时应当使用结构相等?如果变量的值不会改变更,那么几乎任何情况下都应该使用结构相等!我所知的绝大多数编程语言在诸如integers之类的类型上都使用结构比较。除了Java之外,int类型进行结构比较,Integer类型进行引用比较,这迷惑了一代又一代的程序员。Python的is也有类似的问题。

对于引用类型(如对象)也应当进行结构比较。考虑一个单元测试,你希望检查返回的对象是否等于你期待的值。在使用结构相等的语言中,这个操作非常简单:

[<TestMethod>]
let ``The result of the calculation is the expected value``() = 
    let expected = { SomeField = "Some value"; SomeOtherField = 15; StillAnotherField = true; ... }
    let actual = calculate()
    Assert.AreEqual(expected, actual)

但如果语言不支持结构相等,而开发者需要自行开发,就会遇到难题。

引用相等

但正如我刚才说过的那样,某些特定情况下不应该使用结构相等。一种情况就是语言支持变量内容改变的情况,而绝大多数编程语言都支持。当某个变量的值被改变后,说这个变量等于另一个变量显然是不合理的。当然,你可以说在进行比较的时刻,这两个变量(在结构上)是相等的,比如在单元测试的最后一行时是相等的,但一般情况下你无法假设这两个变量是同一个东西。这点理解起来有些困难,我来举例说明。

我们假设有一个对象,表示一个人。在采用了结构相等的F#中,我可以这样写:

type Person = { Name : string; Age : integer; Offspring : Person list }

现在我有两个朋友Jane和Sue,她们都有一个叫John的儿子,年龄都是15岁。他们是不同的人,但姓名和年龄都一样。没问题!

let jane = { Name = "Jane"; Age = 47; Offspring = [ { Name = "John"; Age = 15; Offspring = [] } ] }
let sue  = { Name = "Sue";  Age = 35; Offspring = [ { Name = "John"; Age = 15; Offspring = [] } ] }

也可以这样写:

let john = { Name = "John"; Age = 15; Offspring = [] };
let jane = { Name = "Jane"; Age = 47; Offspring = [ john ] }
let sue  = { Name = "Sue";  Age = 35; Offspring = [ john ] }

这两段代码的功能完全一样。我没办法区别两个儿子,即使我知道他们是不同的人。但这没有问题!如果我需要区别他们,我可以把他们DNA的hash之类的属性加到Person类型中。但如果我只需要知道他们的名字和年龄,那么是否能区分两个对象并不重要,因为不管怎么区分,它们的值都是一样的。

假设Jane的儿子改名成Pat。F#不支持改变变量的值,所以我需要为John(还有Jane!)创建新的Person实例:

let newJane = { Name = "Jane"; Age = 47; Offspring = [ { Name = "Pat"; Age = 15; Offspring = [] } ] }

这个新的变量newJane似乎有点奇怪,但实际上并不会构成问题。上面的代码没有问题。现在用C#试一下,在C#中,变量默认情况下是可以修改的:

var john = new Person("John", 15, null);
var jane = new Person("Jane", 15, new List<Person> { john });
var sue  = new Person("Sue",  15, new List<Person> { john });

这段代码显然是不正确的:如果Jane的儿子改名为Pat,我可以直接改变引用的值:

jane.Offspring.First().Name = "Pat";

但我就会发现Sue的儿子也改名了!因此,即使两个儿子最初的名字是一样的,但他们并不相等!所以我应该写成:

var jane = new Person("Jane", 15, new List<Person> { new Person("John", 15, null) });
var sue  = new Person("Sue",  15, new List<Person> { new Person("John", 15, null) });

这样Jane和Sue的孩子就是引用不相等。所以,在可以改变变量内容的语言中,默认采用引用相等是合理的。

另一种应该采用引用相等的情况是,事先知道引用相等的结果与结构相等相同。测试结构相等显然需要额外开销,如果真的需要测试结构相等,那么这个额外开销是正常的。但是,假设你创建了大量的对象,而且事先知道每个对象都是结构不相等的,那么花费额外开销来测试结构相等是没有必要的,因为仅仅测试引用相等就可以得出同样的结果。

相等性的表示

在实数中,0.999……(无限循环小数)等于1。注意这里说的“实数”与编程语言中的Real类型不一样。在数学中,实数是无限的,而编程语言中的实数是有限的。因此,编程语言中没有0.999……这样的写法,但没关系,你可以使用1,反正两者的值是一样的。

这本质上是数学家在表示实数系统时采用的一种选择。如果在系统中加入另外一种对象,比如无限小的数,那么0.999……和1就不相等了。

“但是这并不等于说规范可以任意确定,因为不接受一种规范,必然会导致不得不发明奇怪的新对象,或者不得不放弃某些熟知的数学规则。”

——Timothy Gowers,《Mathmetics: A Very Short Introduction》

类似地,在实数系统中,1/2和2/4表示同样的值。

不要把这些“相等”与JavaScript或PHP中的“不严格”相等运算符==混淆。这些相等跟那些运算符不一样,这些相等依然遵循相等的定律。重要的是要认识到,对象的相等可以用不同的方式来表达。

在IEEE-754浮点数系统中,-0 = 0。

内涵和外延

一个函数何时等于另一个函数?绝大多数编程语言会进行引用相等的比较,我觉得这没有问题。因为,对函数进行结构比较有什么意义呢?也许我们可以使用反射来检查函数的实现,看看它们实现是否一样?但怎样才叫“一样”?变量名是否必须完全一样?快速排序和归并排序是不是“一样”的函数?

因此我们说,只要函数对于同样的输入返回同样的输出(不管其内部实现如何),函数就是外延相等的,而如果内部定义也一样,则是内涵相等的。当然,这也取决于语境。可能在某个语境中,我需要常数时间的函数,在另一个语境中,速度无关紧要。重要的是,必须有语境才能定义相等,才能用它来比较两个函数。

我不知道是否有哪种语言在比较函数时尝试过采用引用相等之外的方法。但很容易想出,这会很有用!(例如,优化器尝试移除重复的代码等。)你只能自己实现,但我不得不说,没有相等比较,总要比错误的相等比较强。

相等和赋值

当程序员的第一天就学过,“等于”这个名字有两种不同的概念。一种是赋值,另一种是测试相等性。在JavaScript中需要这样写:

const aValue = someFunction(); // 赋值
if (aValue === 3) {            // 测试相等

这两者本质上是不同的。比较返回布尔值,而在面向表达式的语言(如Ruby)中,赋值返回被赋的值。

所以Ruby代码可以这样写:

a = b = c = 3

实际上会把3赋给变量a,b和c。不要在引用类型上尝试,很可能得不到你想要的结果!

在非面向表达式的语言(如C#)中,赋值没有返回值。

在数学中,赋值和测试相等性都使用相等运算符:

if aValue = 3 ... 
where aValue = someFunction()

(而且在数学中,有时候=还用于其他关系,如合同(congruence)。与数学中的其他东西一样,这里也要区分语境;在阅读论文或书籍时必须注意语境。)

为什么数学不要求两种不同的操作,而编程语言要求?因为在数学中可以轻易判断出语境,而且也并非所有语言都要求不同的运算符。例如,F#中赋值和测试相等都采用=。尽管两者采用相同的符号,但赋值和测试相等是完全不同的操作。

let aValue = someFunction(); // 赋值
if aValue = 3 then           // 测试相等

语法的选择部分出于历史原因:F#基于ML,而ML基于数学;而JavaScript的语法基于Java→C→Algo→FORTRAN。

用于编译FORTRAN代码的机器很难根据语法来区分两种情况,因此采用不同的运算符是合理的。于是C语言把这个“特性”带到了新的高度,所以你甚至可以写:

int aValue = someFunction(); // 赋值
if (aValue = 3) {            // 也是赋值!

给没有C语言经验的人解释一下:这段代码先用3覆盖aValue,然后由于表达式aValue = 3的值为3,因此if的条件为真,因此会继续执行if块内的代码。通常这种写法都是错误的,因此许多C程序员会将if块的条件反过来写,来避免造成该错误:

int aValue = someFunction(); // 赋值
if (3 == aValue) {           // 测试相等


// [...]


if (3 = aValue) {            // 语法错误:无法将 aValue 赋值给 3.

相等性的使用错误

通过上面的说明,希望大家都已经明白相等性并不简单,“正确”的实现取决于语境。尽管如此,编程语言经常会把最容易的地方搞错!很多时候,这是相等性与其他语言特性的组合造成的,如隐式类型转换。

常见错误:相等性不是反射的

回忆一下相等性的反射率,即任何值都等于它自身,a = a。

在.NET中,如果在值类型上调用Object.ReferenceEquals(),其参数会在执行方法之前分别进行打包,因此即使传递同一个实例,也会返回假:

(来自文档的例子)

int int1 = 3;
Console.WriteLine(Object.ReferenceEquals(int1, int1)); // 输出 False

这意味着在任何.NET语言中 a = a 都不一定为真,因此不满足反射率。

在SQL中,NULL不等于自身,因此表达式NULL = NULL(或者更可能的情况是,SOME_EXPRESSION = SOME_OTHER_EXPRESSION时两者都可能为null)会返回false。这会导致下面乱糟糟的语句:

WHERE (SOME_EXPRESSION = SOME_OTHER_EXPRESSION)
  OR (SOME_EXPRESSION IS NULL AND SOME_OTHER_EXPRESSION IS NULL)

而更可能发生的情况是,开发者会忘记NULL的特殊规则从而导致bug。一些数据库服务器的SQL语言支持IS NOT DISTINCT FROM,它的功能才是=应该有的功能。(或者我应该说,它没有不做=应该做的事情?)否则,就必须使用上面例子中的SQL语句。最好的解决办法就是尽可能使用不允许NULL的列。

IEEE-754浮点数也有同样的问题,即NaN != NaN。一种解释是,NaN表示某个不确定的“非数字”结果,而不同计算得出的NaN并不一定是同一个不确定的非数字,所以这个比较本身就是不正确的。例如,square_root(-2)和infinity/infinity两者的结果都是NaN,但显然它们不一样!有时候SQL的NULL问题也可以类似地解释。这样造成的问题之一就是术语的含义过多:NaN和NULL表示的是“未知”,还是“不精确的值”,或者是“缺少值”?

对于此类正常的浮点运算中不会出现的问题,解决方法之一就是采用联合(union)类型。在F#中可以这样写:

type MaybeFloat = 
    | Float          of float
    | Imaginary      of real: float * imaginary: float
    | Indeterminate
    | /// ...

然后就可以在计算中正确处理这些情况了。如果在计算中遇到预料之外的NaN,可以使用signaling NaN来抛出异常。

Rust提供了Eq和PartialEq两个trait。没有实现Eq,是==运算符不遵从反射率的一个信号,而Rust中的浮点类型就没有实现Eq。但即使不实现Eq,你依然可以在代码中使用==。实现Eq可以将对象作为hash map的键使用,可能会导致其他地方的行为发生变化。

但是=和浮点数还有更严重的问题。

常见错误:相等过于精确

我想许多开发者都熟悉IEEE-754浮点数的比较问题,因为绝大多数语言的“float”或“double”的实现都是IEEE-754。10 *(0.1) 不等于1,因为“0.1”实际上等于0.100000001490116119384765625,或0.1000000000000000055511151231257827021181583404541015625。如果你对此问题感到陌生,你可以阅读这篇文章(https://randomascii.wordpress.com/2012/02/25/comparing-floating-point-numbers-2012-edition/),但这里的重点是,在浮点数上使用==进行比较是完全不安全的!你必须决定哪些数字是重要的,然后据此进行比较。

(更糟糕的是,浮点数是许多其他类型的基础,如某些语言中的TDateTime类型,所以即使一些相等比较本该合理的地方,也不能正常工作。)

比较浮点数的正确方法是看它们是否“相近”,而“相近”在不同语境下有不同的含义。这并不是简单的==能够完成的。如果你发现经常需要做这种事情,那么也许你该考虑使用其他数据类型,如固定精度的小数。

既然如此,为什么编程语言要在无法支持的类型上提供==比较呢?其实编程语言为每一种类型都提供了==,程序员需要依靠自己的知识来判断哪些不能用。

SML的实现说明(http://sml-family.org/Basis/real.html)上这样说:

判断real是否为相等的类型,如果是,那么相等本身的意义也是有问题的。IEEE指出,零的符号在比较中应当被忽略,而任意一个参数为NaN时,相等比较应当返回false。这些约束对于SML程序员来说非常麻烦。前者意味着 0 = ~0 为true,而r/0 = r/~0为false。后者意味着r = r可能出现返回false的异常情况,或者对于ref cell rr,可能存在 rr = rr 成立但是 !rr = !rr 不成立的情况。我们可以接受零的无符号比较,但是认为相等的反射率、结构相等,以及<>和not o =的等价性应当被保留。这些额外的复杂性让我们作出决定,real不是具有相等性的类型。

通过禁止real拥有=运算,SML强迫开发者思考他们真正需要什么样的比较。我认为这个特性非常好!

F#提供了[<NoEquality>]属性,来标记那些=不应该被使用的自定义类型。遗憾的是,他们并没有将float做上标记!

常见错误:不相等的“相等”

PHP有两个单独的运算符:==和===。==的文档将其称为“相等”,并记载到“如果在类型转换后$a等于$b则返回TRUE”。不幸的是,这意味着==运算符是不可靠的:

<?php
  var_dump("608E-4234" == "272E-3063"); // true
?>

尽管这里比较的是字符串,但PHP发现两者都可以被转换为数字,所以就进行了转换。由于这两个数字非常小(例如第一个数字是608 * 10^-4234),而我们之前说过,浮点数比较非常困难。将这两者都转换成浮点数float(0)将导致它们被四舍五入成同一个值,因此该比较返回真。

注意这与JavaScript的行为不同。JavaScript也有与PHP类似的(但并不是一样的!)==和===运算符;但JavaScript会认为两侧都为字符串,然后返回比较结果false。

幸运的是,PHP提供了===(“全等”)运算符,在这种情况下能给出正确结果。我想说永远不要使用==,但==会在对象上执行结构比较,有时候正是你需要的!因此我只能说,使用==时要格外小心,因为它不能在基础类型上正确工作。

常见错误:相等不是对称的

如果你要在Java中重载.equals(),那么你必须负责确保相等的定律成立!

如果不加注意,那么很容易就会导致不对称的相等,即a.equals(b) != b.equals(a)。

即使不考虑null的情况(因为null会导致NullPointerException,而.equals()是允许这种情况发生的:https://docs.oracle.com/javase/8/docs/api/java/lang/Object.html#equals-java.lang.Object-),如果你继承一个类并重载.equals(),也最好多加小心!

@Override
public boolean equals(Object o) {
    if (this == o)
        return true;
    if (o == null)
        return false;
    if (!o.getClass().isAssignableFrom(getClass())) // 危险!这一步是错的!
        return false;
    ThisClass thisClass = (ThisClass) o;
    // 字段比较
    // ...
}

如果ThisClass和ASubtypeOfThisClass都用类似上面的代码重载了.equals(),那么a.equals(b)就可能不等于b.equals(a)!正确的比较应该是:

   if (getClass() != o.getClass())
        return false;

这不仅仅是我的个人看法,也是Object.equals()的契约的要求(https://docs.oracle.com/javase/8/docs/api/java/lang/Object.html#equals-java.lang.Object-)。

常见错误:相等没有传递性

回忆一下相等比较的定律之一就是应当具有传递性,即如果a  = b 且 b = c,那么 a = c。不幸的是,与类型转换(type coersion)放在一起后,许多语言都会在这里出问题。

在JavaScript中,

'' == 0;      // true
0  == '0';    // true
'' == '0';    // false!

因此在JavaScript中永远不要使用==,应该使用===。

常见错误:相等性不一致

在Kotlin中,==会根据变量类型返回不同的值,即使对于同一个变量:

fun equalsFloat(a: Float, b: Float) {
  println(a == b);
}


fun equalsAny(a: Any, b: Any) {
  println(a == b);
}


fun main(args: Array<String>) {
  val a = Float.NaN;
  val b = Float.NaN;
  equalsFloat(a, b);
  equalsAny(a, b);
}
// prints false, true

这是一个非常不幸的语言特性组合,可能会导致违反直觉的行为。

常见错误:在应当使用结构相等的地方使用引用相等

考虑如下用C#编写的MSTest单元测试:

[TestMethod] 
public void Calculation_Is_Correct() {
    var expected = new Result(SOME_EXPECTED_VALUE);


    var actual = _service.DoCalculation(SOME_INPUT);


    Assert.AreEqual(expected, actual);
}

这段代码能正常工作吗?我们不知道!Assert.AreEqual()最终会调用Object.Equals(),默认会进行引用比较。除非你重载了Result.Equals()进行结构比较,否则这个单元测试无法正常工作。Object.Equals()认为,如果类型是可改变的,那么不应该重载。通常来说这是合理的,但在单元测试中却未必。(这是因为.Equals()本应比较.GetHashCode(),而一个对象的hash code在对象的生命周期中应该不发生改变。).NET framework中对于引用类型的最接近“有保证的结构比较”的是IEquatable<T>,但Assert.AreEqual()并没有使用,即使实现了也不会使用。

而NUnit的情况更糟(https://github.com/nunit/nunit/issues/1249)。

(相反,Java的Object.hashCode在对象的字段发生变化时是允许变化的。https://docs.oracle.com/javase/7/docs/api/java/lang/Object.html#hashCode())

应该怎样看待相等

没想到关于=运算符我写了这么多还没写完!好吧,这已经远远超过了运算符本身。为什么如此复杂?基本上有两个原因:

  • 非本质的复杂性:我们的编程语言在相等比较方面做得并不好。经常完全不能正常工作,甚至在不能正常工作时也不会明确表示这一点,例如会在本应进行引用比较的地方使用结构比较。

  • 本质的复杂性:相等性本身就是复杂的,如比较浮点数。而在诸如比较函数等边缘情况下就更为复杂。

另一种划分方法就是“应该由编程语言的实现者负责解决的问题”(上面的“非本质的复杂性”)和“应该由编程语言的使用者负责解决的问题”。

编程语言应该怎么做?

关于非本质的复杂性,现状是几乎每一种主流编程语言对于相等性的实现都有问题。这个“必须遵循几个定律的简单运算”正是编程语言为了保证正确性而依赖的东西!但在我看来,只有SML真正思考了怎样在语义和运行时/标准库方面同时保证符合定律的相等性,而SML完全不是主流语言。

首先,在禁止相等比较的地方,编程语言应该很容易创建不允许相等比较的类型,因为这易操作完全没有必要复杂(如F#中的[<NoEquality>]),然后应该在标准库中尽可能多地使用该特性,如浮点类型。

编程语言必须非常明确地指出结构相等和引用相等之间的差异。永远都不应该存在行为不确定的情况。绝大多数编程语言会重载==,根据引用的类型(多数情况是根据值或引用的区别),用它来表示结构相等或引用相等,这样做一定会让开发者感到困惑。

Kotlin已经非常接近正确了,它的===表示引用相等,==表示结构相等,尽管出于某些原因,对于值类型它会将===看做==,而不是引发编译错误。目标应该是减少开发者的困惑。它希望让开发者明白,===表示“引用相等”,而不是等号越多越好。

我不知道还有哪些允许改变变量值的语言能够用不困惑的方式处理结构相等的。但很容易想象理想状态应该怎样!准备两个运算符,一个表示结构相等,一个表示引用相当,只在编程语言可以合理地支持的语境下才允许相应的运算符。例如,如果.NET的Object.ReferenceEquals和值类型不进行包裹,并且使用类似于IEquatable<T>的东西允许成功够许愿使用结构相等运算符,那么开发者就很容易弄清楚哪个是哪个。

程序员应该怎么做?

也许你读了这篇文章后会觉得,“哇,相等好复杂!我还是不要编程了,回家种地算了。”但这篇文章如此之长的原因主要是太多的语言都做错了。都作对的确需要些心思,但并不是太难。肯定比种地要简单。

在已有的类型上进行相等比较时,先问问自己:

  • 在这里进行相等比较本身合理吗?

  • 如果合理,那么是应该进行结构比较,还是引用比较?

  • 对于相应的比较方法,我采用的编程语言提供了哪些支持?

  • 我采用的编程语言对于该比较方法的实现是正确的吗?

在设计自定义类型时也可以询问类似的问题:

  • 我的类型应该支持相等比较吗?还是需要一个更复杂的比较,就像float那样?

  • 我的类型应该是可改变的吗?它会对相等性产生怎样的影响?

  • 应该支持引用比较?还是结构比较?还是应该同时支持两者?

如果你的类型是可改变的,则应该考虑将其改成不可改变的。即使语言默认是可改变的,这一点也可以实现!这样做除了能在相等性比较方面获得许多好处之外,不可改变的架构还有许多其他的好处。采用了不可改变数据结构的C# Roslyn编译器就是非常好的例子:

语法树的第三个属性是,它们是不可改变的,而且是线程安全的。这意味着,在获得一棵树之后,它就是当前代码状态的快照,而且永远不会改变。这样多个用户可以在不同的线程中与同一个语法树同时进行操作,而无需担心死锁或重复的问题。由于树是不可改变的,也不能针对树进行直接的改变,因此负责创建和修改语法树的工厂方法实际上会创建树的新快照。树本身的效率很高,因为它会重用底层结点,所以创建新版本的速度很快,只需要使用少量内存。

——.NET Compiler Platform SDK文档

原文:https://www.craigstuntz.com/posts/2020-03-09-equality-is-hard.html

推荐阅读 

现代编程语言大 PK,2020 年开发者关心的七大编程语言!

MySQL 狠甩 Oracle 稳居 Top1,私有云最受重用,大数据人才匮乏!| 中国大数据应用年度报告

如何用CNN玩转AlphaGo版的五子棋?

曾经摸鱼的程序员,如今在武汉自愿加班

区块链和大数据一起能否开启数据完整性的新纪元?

以太坊2.0、分片、DAG、链下状态通道……概述区块链可扩展性的解决方案!

你点的每一个在看,我认真当成了喜欢

Logo

20年前,《新程序员》创刊时,我们的心愿是全面关注程序员成长,中国将拥有新一代世界级的程序员。20年后的今天,我们有了新的使命:助力中国IT技术人成长,成就一亿技术人!

更多推荐