百科问答小站 logo
百科问答小站 font logo



传统的try-catch异常处理是否是编程语言发展中的弯路? 第1页

  

user avatar   xing-jiankuan 网友的相关建议: 
      

我更喜欢用“如何处理好错误”来分析这个问题。至于是不是“异常”,这个“异常”是特制java里的异常还是泛指程序发生了“异常”都是很值得讨论的。

怎样才算是处理错误的正确姿势?

但在讨论所有细节之前,先要确定目标,到底怎么才算是“正确的处理错误”?据我所知,至少有两种不同目标:

  • 第一种是:要编写正确的代码。这里“正确”是指对任何设计上要处理的错误都必须设计处理流程。这类设计在操作系统、驱动、自动控制、存储、基础中间件等领域被使用;
  • 另外一种是:要尽量快速的开发质量可以接受的程序。此时可以忽略一大部分的错误的处理,就是放任。这种开发模式适用于RAD,如互联网领域里大量的上层应用和服务,网站,工具类的脚本等都隶属于这一类。

现实中的项目往往处于这两种模式中间的某个点,靠某一边近一点。

这两种目标存在矛盾。对于第一种开发模式就意味着程序员必须认真的设计正确/错误的代码路径。而我们都知道错误的路径会多很多。但也得必须认真处理。在C写的底层程序里经常会看到每调用一次函数都得跟着一句if判断出不出错。很多第二种的开发人员往往会觉得这种代码很不“优雅”。大量的错误处理路径会夹杂在代码中,显得啰嗦,写起来很不爽,也不便于阅读。但从第一种目标看来,这么做是天经地义,无可厚非的。

而第二种目标是把处理正确逻辑的路径重点完成。只处理少数的必要的错误(比如判断用户授权是否通过)。其余的一般会选择粗略的“集中处理”。比如一个web业务后端代码往往如此。一旦出现任何非预期的情况,会通过异常的方式一路向上抛到顶层,然后被一段集中处理的代码转换成某种4xx/5xx状态码给到调用者。这类程序往往会接入非常全面的监控,来实时抓取运行时发生的各种问题。大部分抓到的问题处理的价值很低,所以程序员还是会继续选择不处理;只有少量真正会影响用户体验,系统安全和稳定的问题会被收集下来处理。

第一种开发模式的程序员往往会对第二种的处理方式嗤之以鼻。但如果按照第一种方式处理,把程序的绝对正确性摆在非常高的位置,反倒会伤害用户的体验。交付给用户的新功能会延期,开发成本因为开发周期延长会急剧提高。而花费很高代价开发的程序可能因为业务的快速变化而被大改甚至丢弃。从商业上这明显不划算,用户也不买账。

这两种开发模式不同会造成任何单一的错误模型很难同时兼顾二者。如果对错误处理要求的很严格,就意味着无法“凑合处理”;反过来如果错误模型很利于快速/忽略处理,就意味着程序员很容易忽略某段代码可能产生的错误。编译器无法有效的引导程序员提高程序的正确性。这种问题可能通过其他手段变相的改进(比如使用静态代码扫描),但总是没有语言built-in用起来自然和舒适。就算是Java这种同时提供了Checked Exception和Unchecked Exception两种机制的语言也总会引发各种争吵。

当然,后面我们会看到go,rust和swift等语言在尝试弥合这两种不同的目标,提供一套统一的错误处理,而不需要程序员根据自己业务的需要二次开发一套错误处理机制。

错误模型如何引导编写正确的代码?

很多同学都喜欢编写“优雅”的代码。这种代码看上去十分“干净”,线性的完成一件任务。阅读起来非常顺畅。但现实情况是,几乎任何一句代码都有可能出错。并不是说你不处理一个错误,那个错误自然就不存在了。

如果想编写绝对正确的代码,就必须每次调用都得处理这个调用可能发生的错误。而且通常一个操作正常的结果就一种情况,但错误的情况却有N种,于是就会出现:

       result, error := doSomething() if error != nil {   // 处理error1   // 处理error2   // …… }  // 使用result      

这种看起来很啰嗦的代码。但从正确性角度,这就是最好的代码了。

所以我们暂时把“优雅”放在一边。先谈正确性的问题。

人是非常容易犯错的。编程语言最好可以提醒程序员不要遗漏任何一个错误。早期C语言编程时,语言并没有内建错误处理,而是靠程序员自己约定和检查错误码。

       int errno = foo(); if (errno) { // errno不是0会被认为是“true”   // handle error } // 正常逻辑     

这很容易出错,程序员很容易就忘记了errno,直接写成:

                foo         ();            

错误就丢了。一旦发生,foo后面的代码的结果就可能是无法预料的。假如这是一段交易所交易的代码,或者武器系统的控制代码,后果就会相当严重。

因此保证正确性的第一个原则是:编程语言应该尽力提醒程序员不要忽略错误。程序员要不处理错误,或者明确告诉编译器这个错误我觉得可以不处理。比如在go中,

       result, error := doSomething()      

如果写成了

       result := doSomething()      

会发生编译错误。

如果程序员想明确地忽略错误,需要

       result, _ := doSomething()      

但可惜的是,go中如果一个函数只会返回一个error,是允许这么写的:

       // 理想中的写法 error := doSomething()  // 这么写可以通过编译器,但IDE的辅助检查可以标记这个问题 doSomething()      

这个设计从“避免人为忘记处理错误”的角度是有瑕疵的,但还好可以通过IDE辅助检查来强制避免忽略检查错误。

在Java中,这个功能是Checked Exception(CE)提供的。

Java Checked Exception

如果一个函数抛出了CE,上层调用者要不得处理,要不得重新上抛。

       public void foo() throw SomeCheckedException {   // do something   throw new SomeCheckedException(); }  public void bar() {   foo(); // 编译不通过,没有处理 SomeCheckedException }  // 明确的处理可以通过编译 public void bar() {   try {     foo();   } catch(SomeCheckedException e) {     // handle the exception   } finally {     // clean up the resource   } }  // 声明自己会"re-throw"foo抛出的Checked Exception也可以通过编译 public void bar() throws SomeCheckedException {    foo();  }     

Java的CE设计的本意是改进C++的exception specification机制。C++ exception specification的设计也是希望帮助程序员避免遗漏错误处理。但其设计的问题更巨大,连基本的静态检查也做不到。已经在2010年C++ 11标准出来是被废弃了,这里就不展开了。

Java 的CE比C++ exception specification的设计好得多,但还是架不住其设计上的缺陷,造成落地时很多人都讨厌他。落地困难。比如很多时候,函数调用者也不知道怎么处理一个Checked Exception。

       try {   byte[] bytes = Files.readAllBytes(aPath);   return new String(bytes); } catch (IOException e) {   // 我知道有这么个exception,但咋处理呢??? }     

一种常见的方式是把下层的Checked Exception直接向上抛。但这又带来一系列的问题。举个例子,比如当你编写一个对象池“object-pool“的接口时,本来borrow函数的签名表示只会在“没有对象可以借”的时候报错:

       interface ObjectPool<T> {   T borrow() throws NoObjectException;   // ... }     

初看起来很正常,但是当你尝试利用这个接口实现数据库连接池时,你会发现JDBC要返回另外一个Checked Exception SQLException

       class DBConnectionPool implements ObjectPool<DBConnection> {   // ...   @Override   public DBConnection borrow() throws /* 这里写啥呢? */ {     doSomethingWithSQLException(); // 怎么处理这里抛出的SQLException呢?   } }     

此时,你有3种选择:

1) 将SQLException包装进一个RuntimeException里。改用RuntimeException。这样一切都能解决。但这就相当于干掉CE。

2)直接向上抛SQLException,但这样需要同时修改DBConnectionPool#borrowObjectPool#borrow的函数签名,否则无法通过编译(实现类的函数不能声明抛出比接口函数声明更多的exception)。如果改了接口,就引起接口兼容性问题。此外,这也要求borrow的使用者增加import SQLExcepton。为了做到这一点,mvn里也许需要增加一个新的dependency。而且

       T borrow() throws NoObjectException, SQLException;     

看起来会非常奇怪,因为直觉上这似乎暴露了borrow的实现细节,同时因为这个接口是通用的,因此用来实现其他什么别的对象池时,因为无法抛出SQLException反而通不过编译。这就影响了接口的通用性。

3)把SQLException包在NoObjectException里面抛出:

       T borrow() throws NoObjectException {   try {     // ...   } catch (SQLException e) {     throw new NoObjectException(e)   } }     

但这样语义上不太对。毕竟“数据库连接失败”和“没有对象可以用”是两件不同的事情。并且这么包完后,如果上层的代码想重试连接数据库呢?看起来又太过于奇怪了:

       try {   DBConnection c = pool.borrow(); } catch(NoObjectException e) {   try {     throw e.cause;   } catch (SQLException) {     // 尝试重新连接数据库   } }     

这样看来,似乎问题是之前那个NoObjectException太过于具体了。如果能稍微抽象一点,让其表意含糊一点似乎就能说得过去了,比如改成PoolException。但如果万一实现还需要抛出其他什么exception,让PoolException都显得不合适呢?能否有个比较通用的办法呢?

James Gosling在一个访谈中表示希望抛出异常能尽量具体,这样有利于调用者搞清楚如何处理问题。

But in general, when you are writing a declaration in a throws clause, it is good to try to be as specific as possible.

但现实中这样做根本就不现实。因为一旦Exception变得具体,就会因为兼容性问题让接口的维护工作变得几乎不可能;即便是可以改变接口签名,也会带来一个函数随着调用层级升高而必须声明大量的Checked Exception问题。前者被称为Versioning问题,后者被称为Scalability问题。这些问题在C#的作者Anders Hejlsberg的一个访谈中提及。

但请不要误解我完全支持Anders的想法和C#的现状。后边讲到Unchecked Exception的地方再说。

除此之外CE在JDK8上遇到了另外一个问题。引入了stream后,因为stream的处理函数都不声明抛异常,因此没法通过这些函数调用抛出CE的函数。于是你看到了Java里的语法在打架。

       void foo(Integer n) throws SomeCheckedException {   // ... }  // 编译不通过,forEach没声明throws SomeCheckedException numbers.stream().forEach(it -> foo(it));     

现在很多Java程序员都完全拒绝使用Checked Exception,也只用RuntimeException来构造自己的错误处理。后果就是会产生很多只有到运行时才能观察到的错误,编译器无法起到帮助程序员写好代码的作用。然而,在Java中这是个很无奈但又很现实的选择。

Checked Exception已经可以被认为是一个失败的设计,正如C++的exception specification一样。但我要再次强调下,我这里提到的CE设计失败并不是指这个设计的本意——避免开发者遗漏错误——是不对的。恰恰相反,我觉得正确性是非常关键和必要的。这里说的失败仅仅是指CE实际效果并没有实现其初步的设计动机。为此,要设计一个更好的模型。

效率优先的“凑合”错误处理

这是大多数互联网公司,或者写了就丢那种程序对错误处理的方式。现实中一般就是用Unchecked Exception。这包括Java中继承于RuntimeException的exceptions,C#,kotlin,javascript,python等中所有的exceptions等。

顾名思义,Unchecked就是“不用必须检查的exception”,但一般会配合一个“兜底处理“。对于这种exception,程序员可以在任何地方throw,也可以在任何地方catch。所以一个Unchecked Exception被抛出后可以跨越很多个调用层次才有可能被catch。如果彻底忘记被catch了(Uncaught Exception),通常就会让程序立刻crash。兜底的错误处理因为距离错误发生的上下文太远,只能做非常粗略的处理。如Web程序会报一个5xx错误;而GUI也许会选择弹出一个信息除了程序员之外谁也看不懂的对话框。

对于脚本语言的场景,或者是逻辑简单,错误处理一般都是兜底就足够的场景,这似乎并不是什么太大的问题。但是如果是系统开发场景(比如写一个中间件或一个存储系统)这就不能接受了,编程语言能提供的帮助实在是太有限了,太不鼓励程序员正经的处理错误了。系统程序一旦出现错误,就不是报个错误信息的问题了,而可能会损毁数据,造成不可恢复的后果。我们都知道人是注定会犯错的,无论这个人的本心如何。

因为用Unchecked Exception写程序太容易漏过错误处理,因此为了保证程序质量,必须更注意编写准确的文档(尤其是会抛出什么exception),必须附加更多的测试,包括程序员自己写的UT,以及专门的测试人员编写的各种集成测试等。当然,如果业务需求来的太快,活催的太紧,这些改善软件质量的工作可能都会被简化和忽略。所以经常可以遇到完全无文档,无UT,以及测试通过手工草草测完了事的程序。于是写出来的程序质量……

然而令人惊讶的是,我们的市场环境在鼓励这样的开发模式。如果是一个初创互联网项目,需要快速启动,快速迭代的服务,如果用正确性优先的做法的团队,一定会被采用效率优先的团队打死。尽管我们都知道长期看,代码质量可以让一个团队走得更远,技术债越少后期负担越轻越灵活,但如果熬不过初期,一切都是无意义的。

回到错误处理,上面提到了Checked Exception因为太过于严格而无法落地,而Unchecked Exception又过于松散,完全放弃编译器的支持,而人总是不可靠的,总是会犯错误。有没有两全的办法呢?Swfit给出了一个还不错的解决方案。

Swift的启示

Swfit的主要是思路是,先承认一个函数是否可能抛出错误,然后再考虑怎么处理具体的错误。这个思路和Go、Rust等语言是非常相似的。下面是一个简单的例子

       // swift通常使用enum表示error enum SomeError: Error {   case reason1   case reason2 }   // 定义时 func foo() throws -> int {  // 正常处理,设置failed   if failed {     throw SomeError.reason1   }   return 42 }  // 调用时 func bar() {   do {     let value = try foo()     // 使用value   } catch SomeError.reason1 {     // handle reason1   } catch SomeError.reason2 {     // handle reason2   } catch {     // handle everything else possible   } }  bar()     

在swfit中,所有错误处理都是"checked"。对于所有错误程序员必须显示的处理,不然编译过不去。而一个函数如果可能抛错,需要在声明时标记throws关键字,但无需声明可能抛什么错误(敲黑板,这是重点)。调用这种throws函数时,swift要求开发者必须使用try关键字调用函数,并且增加必要的do……catch。这就避免了程序员因为马虎大意而忽略了错误处理。

throws无须声明抛的具体错误是相比CE的一个非常大的优势。它解决了CE的scaling和versioning问题。只有当一个函数从不会抛错变为throws时才会有一次签名的变更,之后就不会再有了。而一般设计函数时函数可不可能抛错可以做得比较准确(如排序就不可能抛错,但读配置文件就可以抛错),让改变签名的机会很少。 同时上游也可以只处理自己关心的错误,不用catch自己不理解的error。

并且通常,调用者只会在意调用是否出错,而不在意具体出什么错,就可以这样写:

       do {   let value = try foo()   // 使用value } catch {   // 错误处理 }      

但即便如此,还是比较烦,但swift提供了语法糖进一步简化了程序员的工作。

       // try? 可以让出错时直接返回nil let value = try? foo() //foo抛错后value会得到nil  // 配合??操作符提供一个默认值 let value = (try? foo()) ?? 10 // foo抛错后value会得到10  // try!是一种强检查模式,如果遇到了错误,会让程序立刻crash。 // 也就是说如果程序员认为他的上下文肯定不会遇到错误,他可以选择主动忽略编译器的帮助。 let value = try! foo() // foo抛错后程序会crash     

swift还提供了rethrows关键字解决上面Java CE在lambda表达式遇到的问题。

       // 给Sequence做一个“myMap”高阶函数 extension Sequence {    // rethrows表示忠实的重新抛出其内部抛错函数的错误     public func myMap<T>(_ process: (Element) throws -> T) rethrows -> [T] {       var result = [T]()       for item in self {         result.append(try process(item)) // process是抛错函数所以得用try,但不需要catch       }       return result     } }  func tripleIt(n: Int) throws -> Int {     if n == 2 {       throw SomeError     }     return n * 3 }  // 这样就可以愉快地使用给高阶函数传入抛错函数了 let res = try [1, 2, 3, 4, 5].myMap(tripleIt)     

个人感觉是,Swift以优雅的语法和务实的态度近乎完美的解决了错误处理的问题。

到底何谓错误,何谓异常?

如果留意的话会发现整个文章我都避免用“异常”这个词,而更多的用“错误处理”。因为一个问题是不是“异常”,并不全部取决于问题本身,而与程序员在不在意相关。如果一个错误出了程序员不在意,或者没法在意,那么就是一个异常。异常是程序员不处理的错误。异常意味着程序进入了一个未知的,不确定的状态。

比如OOM,对null解引用这类问题。一旦出现,程序员做什么都没啥意义,无法把程序能从出问题的状态确定性的拉回到可控的状态。

另外一类异常是开发者是主动的不在意。比如一个服务启动时要在一个路径下读取配置文件。但这个文件不存在或者没读取权限。作为服务本身,是可以设计为“一旦找不到配置文件就直接挂掉“的。因为设计一个”没配置文件也可以正常运行“的系统的代价会高的多,但没啥收益。

在编写代码时,如果可以判断出一个问题上层无论如何都处理不了,就建议直接抛出异常。在go中就是panic,rust是panic!,在java中是throw一个Error(比如OutOfMemoryError)。如果上层有可能处理,就还是要以错误的形式向上返回,让上层决定自己到底是不管直接挂掉还是把程序从有问题的状态转变回来。

非常有趣的是,在Java的术语里,“错误”和“异常”和现代语言的术语是相反的。java说的exception往往表达一个“错误”;而java里说的“error”才是几乎无法恢复的问题。这点讨论各种错误模型时要特别留意。

挂掉和稳定性的关系是什么?

很多人误以为一旦遇到异常挂掉就是稳定性降低。但事实刚好相反,因为异常意味着程序运行的状态处于一个非预期的状态,在没有事先设计过错误处理路径的情况下,挂掉是最好的选择。这通常被称为fail-fast。

但只挂掉是远远不够的,不然真的就变成“不稳定”的程序了。配合fail-fast一般配合做两件事。

第一件是灰度开发和发布流程,即可以让不稳定的代码在一个风险可控的范围内跑,尽情的暴露各种问题,尽快的crash,然后开发者尽快去修。所以很多互联网公司都有很多套上线前的环境,从最小的联调环境,到预上线环境,再到部分灰度的线上环境一圈圈扩大范围。苹果iOS每年发布时也是类似,会先在内部alpha1,alpha2……然后经过WWDC后向开发者发布beta1,beta2……直到最后GA。到真正的面向公众时,程序的可靠性已经大大增加了。

另外一件是隔离。当程序crash时是,只会影响到整个系统的优先的范围。这就像是,当一个程序挂了,不会影响操作系统;当Chrome里某几个网页挂了,不会影响到其他Chrome的进程;当一个非核心业务的微服务挂了,上游可以实现降级,服务大体上还是能继续。

在这种隔离体系下,一个挂了的子系统可以被迅速重启,恢复到初始状态。Erlang把这套机制吸收到了自己的语言里,实现了supervisor模式。

当然,给定同样的输入还是可能会继续挂,继续重启——但这种情况要通过灰度开发和发布流程来控制,让在线上跑的系统已经具有极高的稳定性才可行。

而在Web后端经常看到的用"Unchecked Exception报错+兜底处理"的方式也可以看作是一种隔离。因为通常每个请求都是相互独立的,每个请求都与其他请求的上下文是隔离的。某个请求出问题,其实是让这个请求的上下文全部挂掉,而不是让整个web server挂掉。web server只要继续处理下一个请求即可。这是一种非常高效的错误处理。

但这个模式很容易让程序员产生一种误解,即——写代码可以不处理错误,反正有地方兜底。但事实恰好相反,这种模式只在并发请求处理的场景中才成立。web后端这个领域已经被web server,web框架,数据库事务等照顾的很体贴了。换一个领域,比如mysql,假如不好好处理错误,共享的B+树,各种buffer和锁信息可能就会搞乱套,一个错误会影响其他数据的正确性。

小结

错误处理的方式是让我们达成开发目标的工具。而在开发中,根据领域不同,要求的正确性也不同。因此选择一个适合自己领域的错误处理方式是很重要的。如果恰好用到的编程语言能够与自己的领域相match,那就再理想不过。

但通常我们无法选择团队的语言,或者自己就只会一种语言,这时就要想办法去搭建自己错误处理。就像是C并不提供内建的错误处理,但程序员还是凭错误码这么简单的机制搞定了复杂的系统开发。在Java中,如果确认Unchecked Exception + 兜底已经可以足够满足场景的需要,大可以放心用。但在一些局部关键的子系统,需要更高正确性,还是可以采用CE或者增加更多的静态代码检查,并且在代码设计时就要设计各种错误的处理流程。

至于说try……catch是不是“弯路”。我认为不是,从C++、Java等经典语言的设计和落地后的效果可以学到很多。在CE设计之初,Gosling是绝对想不到后面大家使用CE时的各种问题的。因此后面的go、rust、swift等语言才能改进自己的错误处理模型。在这个方面,过程比结果可能更重要。

但,无论如何,“编写正确的程序“的大原则不会改变,关键点在于如何表达错误,如何避免开发者遗漏错误,如何组合多个可能发生错误的代码,如何提供语法糖简化一些不得不写的,反复重复的代码。语言设计的关键是不能劝退一个希望写好代码的人


user avatar   Ivony 网友的相关建议: 
      

1、我看不出来异常和资源泄漏之间的关系……你这明显是该用finally的地方没用导致的……

2、仅仅从代价去分析而不考虑异常是怎么来的使得这个分析没啥意义。


异常是怎么来的?

很简单,发明异常的目的就是为了避免接口污染

异常本质上就是一种返回值,一个确定的函数(不考虑虚函数和lambda)会抛出什么异常是完全可以通过静态分析来解决以及可以在语法层面上进行约束的(例如Java的throws)。

你扯那么多所谓的东西,你不从根本上解决掉发明异常的意义,其实都是白扯……

就像你要论证GC的问题,不是去论证GC性能差,卡顿等等乱七八糟的。你应该从根本上讨论不需要GC也能很好的释放内存资源(例如引用计数什么的)。


否则你说这么多有什么意义呢?


其实很多时候争议都在一些细枝末节的细节上,譬如说要不要显示throws,要不要自动rethrow,要不要自动捕获堆栈,要不要强类型等等。本质上来说,异常这玩意儿是客观存在的……


user avatar   pansz 网友的相关建议: 
      

不需要,我已经几乎两年没用过有线充电了。

把常用场合都部署好无线充电以后,真的不用操心换手机换充电器之类的事情。

其实很简单的一个问题:取消耳机口之后,各位是改用L口耳机C口耳机,还是改用无线耳机?我曾经以为会普及L口或者C口耳机,然而现实就是无线耳机开始普及。

无线充电座普及之后,由于它没有插拔,所以她的寿命其实远比手机要长。

--

所以,如果你没有无线充,强烈建议你尝试无线充。


user avatar   ling-jian-94 网友的相关建议: 
      

这两个游戏都有自己的问题。但严重程度完全不一样。

赛博朋克最大的问题是人力不够,没有人手把愿景在限期内做出来,导致后期狂砍。但从已有的成品来看,CDPR是完全有人才有能力把东西做出来的,只不过没时间做。光影效果,已有的垂直城市设计,以及主线和很多支线任务的演出都有毫不输巫师3的气质,尤其是日本城浮空平台那关,无论是游戏流程还是画面还是音乐,都把类似银翼杀手2047的那种气氛和感受做到了极致。有人说CDPR的人才都跑了,或者CDPR傲娇了开始放水,这并不客观。2077确实是个半成品,主机优化的问题尤其严重,但你关注已经完成的部分,用高配置PC玩,其质量并未令人失望,依然是巫师3的水准。

2077就像是一个优等生忘了做背后的几题的考卷,开天窗导致不及格,但已经做了的题目还是正确率极高的。

谈到E3的demo,单从画面上讲你很难说它缩水了。只不过CDPR没告诉你想要E3画面,就得上3080+光线追踪。。。

我猜想没有光追的话,游戏在大多数情况下也是可以达到光追的效果的,只不过人工工作量会很大,有些地方需要离线烘培,而有些地方需要人工设置虚拟光源。CDPR可能发现项目后期工作量太大搂不住了,就上了光追这个大杀器。。。


至于无人深空,现在口碑很好,但我要不客气地讲,这个游戏到了今天依然是垃圾,只配卖$19.95,打折的时候卖2.95的那种。

Hello工作室自始自终都没有把初始愿景实现的技术能力。

你可以看无人深空进入大气层的技术实现。先是一段飞船进入大气层摩擦发红的特效,然后可以看见地形通过一种非常粗糙、视距很近的情况下刷新出来,并且刷出来的地貌和太空中看到的地貌完全不同。所以从头到尾,hello工作室都没有类似精英危险和星际公民的无缝行星登陆技术。

无人深空更新了十几次,并没有触动这个游戏除了机械刷就没有任何深度的本质。这是一个极其无聊的游戏。但它刷了两年的DLC,玩家也就给他点面子,没功劳有苦劳。它每次更新我都会进游戏看看,但玩不了半小时就会放弃。一是实在无聊,二是它美术设计和渲染水平有限,色彩及其刺眼。比如在母船机库里,到处都是亮瞎狗眼的点状光源,但这些光源不会照亮周围的任何东西,看的时间长了有种不带护目镜看焊接的流泪效果。你说更新了那么久,这么简单的问题都不解决,有什么用呢。游戏中随处可见低级设计的痕迹,比如说有很多行星上有一种可以卖钱的球,这种球没有任何贴图,只有亮瞎眼的纯白色材质,在HDR效果下极其刺眼,但它又不是个光源,放在地上不会照亮周围任何东西。这种打开Blender就存盘的建模初手垃圾素材居然也能放在游戏里,真是活久见。

所以无人深空就像是一个学渣冒充学霸,把期望提得无限高,却每题都答错结果接近0分,被骂,然后花了漫长的时间在那里订正,一题一题的改,最后终于接近30分了,然后获得了大家的赞赏,全然忘记了它改了那么久依然是不及格。

无人深空的贴图我就不贴了,首发的时候真是纯垃圾,基本上是2008年魔兽世界首发的那个水准。现在也依然是垃圾,开个HDR看着眼睛都疼。




  

相关话题

  如果华为自己做手机操作系统,你对这个操作系统有什么建议? 
  如何有效阅读Github上开源项目代码? 
  同样是巨头的语言,为什么中国是 Go 最热的国家,而 C# 越来越少? 
  零基础如何学爬虫技术? 
  为什么C/C++中“(*p).number”和“p->number”作用一样但却有两种写法? 
  算法书如何选择? 
  你见过哪些奇趣的代码注释? 
  为什么国内创造不出很棒的编程语言? 
  你见过的最想笑的,最奇葩的,最逗逼的代码是什么? 
  递归和循环相比耗费更多的空间,对于循环来说除了可以简化逻辑外还有什么优点吗? 

前一个讨论
「重载」的正确读音是什么?
下一个讨论
香港宽频逾期9000块钱不缴费会怎样?





© 2024-11-24 - tinynew.org. All Rights Reserved.
© 2024-11-24 - tinynew.org. 保留所有权利