2345首页 » 微信公众号精选 » PHP 8.3 新特性解读

作者:InfoQ
账号:infoqchina
签名:为一线互联网公司核心技术人员提供优质内容。科技圈的观察者,前沿技术的传播者。
发布时间:2024-03-11 14:52:15

作者 | Deepak Vohra
译者 | 明知山
策划 | 丁晓昀
本文是 PHP 8.x 系列文章的一部分。你可以通过订阅 RSS 来接收有关本系列文章的更新通知。

PHP 仍然是互联网上使用最广泛的脚本语言之一,w3tech 的数据显示,有 77.3% 使用服务器端编程语言的网站都在使用它。PHP 8 带来了许多新特性和改进,我们将在本系列文章中进行探讨。

PHP 8.3 是 PHP 8.x 系列最新的主要更新版本。

除了性能改进之外,它还带来了许多新特性,包括修正了在 PHP 8.1 中引入的 readonly 特性;显式类型化的类常量;一个新的用于标记覆盖超类方法的 #[\Override] 属性,等等。

环境设置

下载并安装 PHP 8.3 二进制文件。在本系列前几篇文章中,我们使用了 Windows 操作系统。为了与此保持一致,请下载并安装 PHP 8.3 Windows 二进制文件。按照 PHP 7——入门及面向对象编程改进中的说明来设置环境。最后,在命令行运行 php --version 验证 PHP 版本是否为 8.3。

新的 increment 和 decrement 运算符

PHP 8.3 引入了新的增减函数 str_increment(string $string) 和 str_decrement(string $string),它们通过加减 1 来实现对参数的增减操作。换句话说,$v++ 等同于 $v += 1,$v-- 等同于 $v -= 1。

对于以下任一情况,函数将抛出 ValueError:

  • $string 为空字符串;

  • $string 不是由字母和数字 ASCII 字符组成。

另外,如果字符串无法执行减操作,str_decrement 函数会抛出 ValueError。例如,“A”或“0”无法再减。对非字母数字字符串的增减操作已被弃用。可以被表示为科学记数法的数字字符串不执行类型转换。

在下面的示例脚本中,str_increment(string $string) 函数调用将对一个字母数字字符串的值进行增操作。str_decrement(string $string) 函数将对字母数字字符串的值进行减操作。脚本还演示了函数的参数必须是字母数字字符串,否则将抛出 ValueError:

<?php $str = "1";$str = str_increment($str);echo var_dump($str);  
$str = "1";$str = str_decrement($str);echo var_dump($str);
$str = "-1";$str = str_decrement($str);echo var_dump($str); ?>

运行脚本,得到以下输出:

string(1) "2" string(1) "0"Uncaught ValueError: str_decrement(): Argument #1 ($string) must be composed only of alphanumeric ASCII characters ...

bool类型的增减操作不会有任何效果,但会生成警告。同样,对空字符串的增减操作已被弃用。此外需要注意的是,增减非数字字符串都没有效果,并已被弃用。作为演示,请运行以下脚本:

<?php // decrementing empty string$str = "";--$str;  echo var_dump($str);// decrementing non-numeric string$str = "input";--$str;  echo var_dump($str);// incrementing empty string$str = "";++$str;  echo var_dump($str);  // incrementing non-numeric string string$str = "input";++$str;  echo var_dump($str); 

输出包含了弃用消息:

Deprecated: Decrement on empty string is deprecated as non-numeric inint(-1)Deprecated: Decrement on non-numeric string has no effect and is deprecated instring(5) "input"Deprecated: Increment on non-alphanumeric string is deprecated instring(1) "1"string(5) "input"

不过,字母数字字符串可以被增减,尽管输出可能并不总是可预测的。运行下面的脚本:

<?php$str = "start9";$str = str_increment($str);echo var_dump($str);$str = "end0";$str = str_decrement($str);echo var_dump($str);   $str = "AA";$str = str_decrement($str);echo var_dump($str); 

输出如下:

string(5) "input"string(6) "staru0"string(4) "enc9"string(1) "Z"

字符串参数必须在范围内,以免出现溢出。作为演示,运行下面的脚本:

<?php$str = "00";$str = str_decrement($str);echo var_dump($str);  

输出了ValueError

Uncaught ValueError: str_decrement(): Argument #1 ($string) "00" is out of decrement range
新的#[\Override] 属性

PHP 8.3 引入#[\Override] 属性,用于显式声明覆盖方法。新引入的#[\Override] 属性用于消除在方法覆盖方面存在的歧义。方法覆盖声明可能会有什么歧义?PHP 会验证覆盖方法的签名与父类中被覆盖的方法是否兼容以及从接口继承的实现方法与给定接口是否兼容。PHP 不会验证一个方法是否打算重写父类已有的方法。

PHP 不验证一个方法是否打算实现接口中的方法。如果使用新的#[\Override] 属性声明了意图,那么对于因方法签名相似性、拼写错误导致被误认为是重写方法而实际上并非如此的代码,都更容易进行调试。显式标记覆盖方法(无论是来自超类还是接口)可用于许多目的,包括:

  • 使调试更容易。

  • 重构和清理已有代码。

  • 检测由开发库提供的超类中可能产生的破坏性变更。

PHP 引擎是如何解释新的#[\Override] 属性的?如果该属性被添加到方法中,引擎在编译时会验证父类或实现的接口中是否存在同名方法。如果没有这样的方法,就会生成编译时错误。#[\Override] 属性不会改变覆盖方法的规则和语法,它只是向编译器提供了一个提示。#[\Override] 属性将在以下几种情况下发生作用:

  • 类或接口公共和受保护的方法,包括抽象方法和静态方法。

  • 使用的 trait 的抽象方法(使用的 trait是指在类中通过use关键字来使用的 trait),如随后的示例所示。

在下面的示例脚本中,类 B 扩展了类 A 并覆盖了三个方法fn1fn2fn3

<?phpclass A {    protected function fn1(): void {}    public function fn2(): void {}    private function fn3(): void {}}class B extends A {    #[\Override]    public function fn1(): void {}   #[\Override]    public function fn2(): void {}    #[\Override]    public function fn3(): void {}  }

运行脚本后,前两个方法满足#[\Override] 属性的条件,但fn3不满足,因为fn3是一个私有方法。

B::fn3() has #[\Override] attribute, but no //matching parent method exists ...

在下面的示例中,一个类扩展了另一个类并实现了一个接口,覆盖了其唯一的方法。#[\Override] 属性放在覆盖方法上。

<?phpinterface B {    public function fn(): void;}class A {    public function fn(): void {}  }class C extends A implements B {#[\Override]public function fn(): void {}}?>

超类中必须存在匹配的方法。作为演示,请运行下面的脚本,其中#[\Override] 属性放置在没有与超类匹配的方法上。

<?phpclass Hello{    #[\Override]    public function hello(): void {}  }?>

这将生成错误消息:

Hello::hello() has #[\Override] attribute, but no matching parent method exists ...

#[\Override] 属性无法被应用于__construct()方法,如下面的脚本所示:

<?phpclass Obj  {    private $data;    #[\Override]    public function __construct() {        $this->data = "some data";    }}

这将生成错误消息:

Obj::__construct() has #[\Override] attribute, but no matching parent method exists ...

如果 trait 没有被用在类中,那么 trait 方法上的#[\Override] 属性将被忽略。可以在 trait 的方法上声明#[\Override] 属性,如下所示:

<?phptrait Hello {    #[\Override]    public function hello(): void {}}?>

但是,如果 trait 在类中被使用,则不能在 trait 的方法上声明#[\Override] 属性,除非该方法也存在于超类中。例如:

<?phptrait Hello {    #[\Override]    public function hello(): void {}}class C {    use Hello;  }?>

这将生成错误消息:

C::hello() has #[\Override] attribute, but no matching parent method exists ...

当然,并不是所有来自父类、接口或被使用的 trait 的方法都必须被覆盖。如果没有提供实现,从父类、接口或 trait 继承了抽象方法的类可以被声明为抽象的。但是,当一个类确实覆盖了来自被使用的 trait、接口或超类的方法时,最好(尽管不是必须的)用#[\Override] 属性标记覆盖的方法。

在类中重写了来自使用的 trait 的抽象方法满足#[\Override] 属性。这意味着从类中使用的 trait 继承的抽象方法可以在类中使用#[\Override] 属性进行标记,表明这是一个覆盖的方法。在下面的示例脚本中,方法hello上的#[\Override] 属性表明了打算覆盖 trait 的hello()抽象方法的意图。

<?phptrait Hello {    abstract public function hello();    public function hello2() {}}class HelloClass {    use Hello;    #[\Override]    public function hello() {        return "hello";    }}?>

不过,从使用的 trait 继承的普通方法不能使用#[\Override] 属性标记,因为它实际上并不会覆盖任何方法,只是“遮蔽”来自 trait 的同名方法。作为演示,请看下面的脚本:

<?phptrait Hello {    public function hello(){}    public function hello2() {}}class HelloClass {    use Hello;    #[\Override]    public function hello() {        return "hello";    }}?>

#[\Override] 属性表明了有意覆盖某些方法,但该类只是在“遮蔽”一个与 trait 中同名的方法。下面的脚本将生成错误消息:

HelloClass::hello() has #[\Override] attribute, but no matching parent method exists  

#[\Override] 属性可以与枚举一起使用。例如,声明一个接口,并在枚举中实现该接口,然后在枚举中覆盖接口的方法。

<?phpinterface Rectangle {    public function rect(): string;}enum Geometry implements Rectangle {    case Square;    case Line;    case Point;    case Polygon;
#[\Override] public function rect(): string{ return "Rectangle"; }}

#[\Override] 属性可以与匿名类一起使用。例如:

<?phpclass Hello {public function hello() {}}interface HelloI {public function hello2();}var_dump(new class() extends Hello implements HelloI {   #[\Override]  public function hello() {}   #[\Override]  public function hello2() {}});?>

脚本的输出如下:

object(Hello@anonymous)#1 (0) { }
任意静态变量初始化器

PHP 8.3 增加了对静态变量初始化器中非常量表达式的支持。在下面的示例中,fn2()中的静态变量初始化器是一个函数调用,而不是一个常量。

<?phpfunction fn1() {    return 5;}
function fn2() { static $i = fn1(); echo $i++, "\n";}fn2();?>

当调用该函数时,脚本返回值为 5。

在 PHP 8.3 之前支持的重新声明静态变量在 PHP 8.3 中不再受支持。下面的脚本重新声明了一个静态变量初始化器。

<?phpfunction fn1() {    return 5;}function fn2() {    static $i = 1;    static $i = fn1();}fn2();?>

当运行脚本时,将生成错误消息:

Duplicate declaration of static variable $i ... 

支持非常量表达式的一个副作用是,ReflectionFunction::getStaticVariables()方法可能无法确定静态变量的值,因为静态变量初始化器使用的表达式的值仅在调用函数后才知道。如果在编译时无法确定静态变量的值,则返回NULL值,如下面的示例所示:

<?phpfunction getInitValue($initValue) {    static $i = $initValue;}var_dump((new ReflectionFunction('getInitValue'))->getStaticVariables()['i']);

接下来,修改脚本,让它调用 getInitValue 函数,该函数会初始化静态变量:

<?phpfunction getInitValue($initValue) {    static $i = $initValue;}getInitValue(1);var_dump((new ReflectionFunction('getInitValue'))->getStaticVariables()['i']);?>

这次,同样的ReflectionFunction调用返回int(1)的初始化值。

但是,一旦值被添加到静态变量表中,它就不能用另一个函数调用来重新初始化,例如:

getInitValue(2);

静态变量的值仍然是int(1),如下面脚本的输出所示:int(1) int(1)

<?phpfunction getInitValue($initValue) {    static $i = $initValue;}getInitValue(1);var_dump((new ReflectionFunction('getInitValue'))->getStaticVariables()['i']);getInitValue(2); var_dump((new ReflectionFunction('getInitValue'))->getStaticVariables()['i']);?>

允许在静态变量初始化器中使用非常量表达式的另一个副作用是,如果在初始化过程中抛出异常,则静态变量不会被显式初始化,且初始值为NULL,但后续的调用可能会初始化静态变量。

另一个副作用是,依赖于另一个静态变量的静态变量的初始值在编译时是未知的。在下面的脚本中,静态变量$b的值仅在调用setInitValue()之后才知道。

<?phpfunction setInitValue() {   static $a = 0;   static $b = $a + 1;   var_dump($b);}setInitValue();?>

输出是:

int(1)
动态类常量查找

PHP 8.3 引入了新的查找类常量的语法。在 PHP 8.3 之前,必须使用constant()函数来查找类常量,如下所示:

<?phpclass C {    const  string SOME_CONSTANT = 'SCRIPT_LANG';}
$some_constant = 'SOME_CONSTANT';
var_dump(constant(C::class . "::{$some_constant}"));

输出是:

string(11) "SCRIPT_LANG"

在 PHP 8.3 中,查找类常量的语法简化如下:

<?phpclass C {    const  string SOME_CONSTANT = 'SCRIPT_LANG';}
$some_constant = 'SOME_CONSTANT';
var_dump(C::{$some_constant});

输出是:

string(11) "SCRIPT_LANG"
新的只读特性

正如我们在本系列之前的文章中所描述的,readonly属性是在 PHP 8.1 中引入的,而readonly类是在 PHP 8.2 中添加的。PHP 8.3 通过添加两个新特性进一步扩展了readonly的功能:

  • 在克隆期间,可以重新初始化只读属性。

  • 非只读类可以扩展只读类。

可以在克隆过程中重新初始化只读属性

对于readonly属性的深度克隆,可以在克隆过程中重新初始化readonly属性。我们先从一个深度克隆示例开始,该示例在使用 PHP 8.2 运行时会失败。下面脚本中的readonly属性是RO::$c

<?phpclass C {    public string $msg = 'Hello';}
readonly class RO { public function __construct( public C $c ) {}
public function __clone(): void { $this->c = clone $this->c; }}
$instance = new RO(new C());$cloned = clone $instance;

当运行脚本时,将生成错误信息:

Uncaught Error: Cannot modify readonly property RO::$c ...

下面的脚本演示了在 PHP 8.3 中修改readonly属性。

<?phpclass C {    public string $msg = 'Hello';}
readonly class RO { public function __construct( public C $c ) {}
public function __clone(): void { $this->c = clone $this->c; }}
$instance = new RO(new C());$cloned = clone $instance;$cloned->c->msg = 'hello';echo $cloned->c->msg;

输出如下:

hello

重新初始化只在调用__clone()方法期间执行。被克隆的原始对象不会被修改,只有新实例可以被修改。因此,从技术上讲,对象仍然是不变的。重新初始化只能执行一次。取消readonly属性的赋值也被视为重新初始化。

在下面的示例中,类A声明了两个readonly属性$a$b,它们由__construct()函数初始化。__clone()方法重新初始化了readonly属性$a,而readonly属性$b通过调用cloneB()函数取消赋值。

<?phpclass A {   public readonly int $a;   public readonly int $b;   public function __construct(int $a,int $b) {            $this->a = $a;       $this->b = $b;   } public function __clone(){        $this->a = clone $this->a;          $this->cloneB();    }    private function cloneB(){        unset($this->b);      }}

克隆对象并修改其readonly属性不会更改原始对象的readonly属性值。

$A = new A(1,2);echo $A->a;echo $A->b;$A2 = clone $A;echo $A->a;echo $A->b;

readonly属性保持相同的值,分别为 1 和 2。

重新初始化相同的readonly属性两次会报错。例如,如果将下面的代码行添加到__clone()方法中:

$this->a = clone $this->a;

这将生成以下错误消息:

Uncaught Error: __clone method called on non-object ...
非只读类可以扩展只读类

在 PHP 8.3 中,非readonly类可以扩展readonly类。例如,下面的脚本声明了一个readonlyA,其中包含了三个隐式readonly的属性。readonly属性在类构造函数中初始化。

<?phpreadonly class A{    public int $a;    public string $b;    public array $c;
public function __construct() { $this->a = 1; $this->b = "hello"; $this->c = ["1" => "one", "2" => "two"]; }}

然后,非readonly的类B扩展了类A

class B extends A {}

在 PHP 8.2 中,这个类将会报错:

Non-readonly class B cannot extend readonly class A ...

但是,在扩展类中不能重新定义readonlyA中的属性,因为这些属性隐式为readonly

class B extends A {public int $a;}

一个readonly类仍然无法扩展一个非readonly类。

readonly类扩展readonly类不会使扩展类隐式成为readonly的。

虽然readonly类不能声明无类型属性或静态属性,但非readonly类扩展readonly类可以声明无类型属性或静态属性,如下面的脚本所示:

<?phptrait T {    public $a1;        // Untyped property}class B extends A {    use T;    public static $a2; // Static property}
类型化类常量

PHP 8.3 增加了对类型化类常量的支持。类型化类常量可以添加到类、接口、枚举和 trait 中。类型化类常量意味着类常量可以与显式类型关联。

在 PHP 8.3 之前,类常量没有显式类型,因此子类可以分配与定义类中使用的类型不同的类型。在 PHP 8.3 中,常量可以被类型化,例如使用string类型。即使在派生类中,string类型的常量只能被赋string值,而不能被赋其他类型的值。

在下面的示例中,将int类型的常量赋为string值。

<?phpinterface I {    const  string SOME_CONSTANT = 'SCRIPT_LANG';}
class C implements I { const int ANOTHER_CONSTANT = I::SOME_CONSTANT;}

这将生成错误消息:

Cannot use int as value for class constant C::ANOTHER_CONSTANT of type string

mixed类型可以像下面这样赋值给常量:

<?phpinterface I {    const  string SOME_CONSTANT = 'SCRIPT_LANG';}
class C implements I { const mixed ANOTHER_CONSTANT = 1;}

除了voidnevercallable之外,任何 PHP 类型都可以赋值给类常量。

Randomizer 类的新增内容

PHP 8.3 向\Random\Randomizer类添加了三个新方法。这些方法提供了常见的功能。其中一个函数从给定的字符串生成随机选择的字节,另外两个函数生成随机浮点数。

新的 Randomizer::getBytesFromString() 方法

这个方法返回一个指定长度、由给定字符串中随机选择字节组成的字符串。

方法定义如下:

public function getBytesFromString(string $string, int $length): string {}  

下面是调用这个方法的示例脚本:

<?php
$randomizer = new \Random\Randomizer();
$bytes = $randomizer->getBytesFromString( 'some string input', 10);
echo bin2hex($bytes);

输出为:

7467736f7473676e6573
新的 Randomizer::getFloat() 和 Randomizer::nextFloat() 方法

getFloat() 方法返回一个介于指定的最小值和最大值之间的随机浮点数。

$boundary 参数值决定了 $min$max 值是否包含在内。换句话说,$boundary 参数决定了返回的值是否可以是 $min$max

枚举值决定了间隔,如下表所示:

例如,getFloat() 返回介于 1 和 2 之间的随机浮点数:

<?php
$randomizer = new \Random\Randomizer();
$f = $randomizer->getFloat(1,2);
echo var_dump($f);

输出如下:

float(1.3471317682766972)

$max 参数必须大于 $min 参数,但必须是有限的。

下面的示例演示了如何指定边界。

<?php
$randomizer = new \Random\Randomizer();
$f = $randomizer->getFloat(1,3,\Random\IntervalBoundary::OpenOpen);
echo var_dump($f);$f = $randomizer->getFloat(1,3,\Random\IntervalBoundary::ClosedOpen);
echo var_dump($f);$f = $randomizer->getFloat(1,3,\Random\IntervalBoundary::OpenClosed);
echo var_dump($f);$f = $randomizer->getFloat(1,3,\Random\IntervalBoundary::ClosedClosed);
echo var_dump($f);

多次调用脚本生成的输出为:

float(2.121058113021827) float(1.4655805702205025) float(1.8986188544040883) float(1.2991440175378313)
float(2.604249570497203) float(1.8832264253121545) float(2.127150199054182) float(2.5601957175378405)
float(2.0536414161355174) float(2.5310859684773384) float(1.5561747808441186) float(2.4747482582046323)
float(2.8801657134532497) float(1.9661050785744774) float(1.0275149347491048) float(2.6876762894295947)
float(2.0566100272261596) float(1.2481323630515981) float(2.378377362548793) float(2.365791373823495)

nextFloat() 方法返回一个介于 [0, 1) 的随机浮点数。这个方法等同于getFloat(0, 1, \Random\IntervalBoundary::ClosedOpen)

unserialize() 针对尾部数据
生成警告消息

unserialize() 函数之前只考虑了主要数据,忽略了序列化值尾部分隔符后的数据,即用于标量的‘;‘和用于数组和对象的‘}’。在 PHP 8.3 中,尾部的字节不再被忽略,它会输出一条警告消息,例如:

<?phpvar_dump(unserialize('i:1;'));var_dump(unserialize('b:1;i:2;'));

这将生成警告消息:

unserialize(): Extra data starting at offset 4 of 8 byte ...
新的 json_validate() 函数

PHP 8.3 添加了一个非常有用的新函数,用于验证字符串参数是否为有效的 JSON。字符串参数必须是 UTF-8 编码的字符串。该函数返回一个布尔值(true 或 false),表示字符串是否为有效的 JSON。在 PHP 8.3 之前,需要创建一个自定义函数来验证 JSON,如下所示:

<?phpfunction json_validate(string $string): bool {    json_decode($string);
return json_last_error() === JSON_ERROR_NONE;}

json_validate() 不是内置函数。下面这个不包含自定义函数定义的脚本在 php 8.3 之前版本运行时会报错:

<?php 
var_dump(json_validate('{ "obj": { "k": "v" } }'));

错误消息为:

Uncaught Error: Call to undefined function json_validate()

有了 PHP 8.3 中新的 json_validate() 函数的支持,下面的脚本可以正常运行:

<?php
var_dump(json_validate('{ "obj": { "k": "v" } }'));

输出为:

bool(true)
被弃用的小功能

PHP 8.3 弃用了一些未被使用的小功能。

将负的 $widths 传给 mb_strimwidth() 已被弃用。要使用这个函数,必须在 php.ini 配置文件中启用多字节扩展:

extension=mbstring

下面的脚本向函数传入 -2

<?phpecho mb_strimwidth("Hello", 0, -2, "...");?>

在运行脚本时,将输出弃用消息:

Deprecated: mb_strimwidth(): passing a negative integer to argument #3 ($width) is deprecate...

其次,NumberFormatter::TYPE_CURRENCY 常量已被弃用。用使用这个常量,需要启用国际化扩展。

extension=intl

运行下面的脚本:

<?php$fmt = numfmt_create( 'de_DE', NumberFormatter::TYPE_CURRENCY);$data = numfmt_format($fmt, 1234567.891234567890000);?>

将输出弃用消息:

Deprecated: Constant NumberFormatter::TYPE_CURRENCY is deprecated in C:\PHP\scripts\sample.php on line 2

MT_RAND_PHP 常量是为特殊情况实现而引入的,没有任何重要的用途,因此已被弃用。运行下面使用该常量的脚本。

<?phpecho mt_rand(1,  MT_RAND_PHP), "\n";

将输出弃用消息:

Deprecated: Constant MT_RAND_PHP is deprecated ...

ldap_connect 函数,用于检查给定的连接参数是否可以连接到 LDAP 服务器,已弃用单独指定主机和端口的函数签名:

ldap_connect(?string $host = null, int $port = 389): LDAP\Connection|false

要使用这个函数,需要在 php.ini 中启用 ldap 扩展。

extension=ldap

运行下面的脚本:

<?php$host ='example.com';$port =389;ldap_connect($host,$port;?>

将输出弃用消息:

Deprecated: Usage of ldap_connect with two arguments is deprecated ...
总    结

回顾一下,本文讨论了 PHP 8.3 中的一些重要新特性,包括对之前 8.x 版本中引入的只读特性的修正、用于显式表达覆盖方法意图的#[\Override] 属性、显式类型化的类常量,以及新的用于验证 JSON 字符串的json_validate()函数。

本文是 PHP 8.x 系列文章的一部分。你可以通过订阅 RSS 来接收有关本系列文章的更新通知。

PHP 仍然是互联网上使用最广泛的脚本语言之一,w3tech 的数据显示,有 77.3% 使用服务器端编程语言的网站都在使用它。PHP 8 带来了许多新特性和改进,我们将在本系列文章中进行探讨。

查看英文原文:

https://www.infoq.com/articles/whats-new-php-8-3/

声明:本文为 InfoQ 翻译,未经许可禁止转载。

今日好文推荐

微软 Copilot 生成暴力色情图且拒不更改,内部工程师绝望举报至政府!

奥特曼无罪重返董事会!谷歌华人工程师被捕:号称自己能力“全球仅10个”;美国要求字节跳动半年内剥离TikTok  | Q资讯

谷歌:不建议未成年人接触 C++,太过危险!Yann LeCun 和马斯克看到都笑了

马斯克最新回应:OpenAI 的“邮件攻击”在说谎!斯诺登力挺:OpenAI 这么做是反人类!

其他文章: