梦断代码--一个程序员的自白(八)

25 篇文章 0 订阅

本文谢绝转载 http://www.weibo.com/0x2b

因为最初我只是想证明我的那个可扩展的类型系统,因此我给ADPLite改了个名字,叫“息壤”,喻意类型系统会像息壤一样,能够生长。到11年夏天的时候,息壤已经有了一些东西:

    1.DBC库--这个是以前的工作。除了pre-condition,post-condition,invariant三个宏之外,允许用户自定义和设置错误处理句柄。从而支持测试和特殊需求。
    3.buffer,和vector类似除了a)只接受pod类型,b)resize的时候不会初始化对象。我用它来完成各种基于内存块的操作。
    4.iterator,一个擦除了真实迭代器类型,但保留元素类型的模板类。
    5.string,三个不同用途的string的实现。
    6.exception,定义异常的辅助宏,和辅助收集异常抛出点的上下文。
    8.archive,一组相当于iostream的接口,但是简化许多。两组实现,一组是内存的(5个),另一组是文件(一个,mmap实现).
    9.一个类型系统的抽象实现,也是息壤名称的来源。
    10.vfs.一个virtual file system的接口。这个当然不是用在OS上的,也是相当简化。有一个基于内存的实现,一个本地文件的,和一个能用,但是未完工的zip的实现。
    11.range,一容器的方式提供迭代器对,避免不必要的容器复制。
    ......

    虽然这些东西只是业余写写的,且断断续续,但还是严格地遵守了这些原则:正交分解,接口最小化,依赖倒置,区分方法和查询,Liskov替换,Open-close,异常安全等等。就这么丢了有点可惜,我何不花点时间把它写完呢?第一步,先清理这些代码。我删掉了一些练习和不成功的尝试,如一个STL风格的tree,可是最后却发现并不好用,关键是不能定制children的管理方式,这变得很鸡肋。一个奇怪的,编译期解析的配置文件读取类等等。一个完善现有STL迭代器分类的尝试。也保留了一些不完善,但是有用的东西,如range,tuple。写tuple完全是因为不想在接口中暴露boost,当然最后还是放弃tuple了,这其实是个很不情愿的决定。

    在写息壤的过程中,有些决定很明智,有些则显得愚蠢。但无论如何,没有放松对质量和性能的要求。无论对错,我希望把这些内容记录下来,供借鉴或是批评。

    .DBC库的三个宏,最初的实现是支持搜集任意数量的错误点上下文的,但是因为对文本转换的支持不够理想而去掉了(在异常支持组件仍然保留了这一设计,但是实现手法并不相同)。用户可以通过一个额外的宏来控制是否生效而不仅仅是依赖Debug/Release编译宏。

    .Iterator本来有两个目标,一是擦除迭代器类型,二是实现新的迭代器分类,把遍历和读写访问分解。曾经有文章批评STL迭代器把遍历方式和访问性这两个正交的概念混在一起,导致一些棘手的问题,理想的做法是将之分离。因此读、写构成可访问性概念,遍历概念包括:可递增的,单遍,前向,双向,随即。我写出了将任意一个STL迭代器映射到新概念的实现,但是无论如何,都显得非常累赘和低效。要做的更好必须重度使用TMP(template meta programming),实现代码不但晦涩而且工作量也不小。这也和我TMP不够纯熟和缺乏支持库有关--boost不能用在这里,C++0x当时我还没打算用。最致命的是,如果我用这样的一个新概念STL,我就在STL和boost中步履维艰,许多必要的算法都不能用了。所以我最后放弃了,仍旧回到STL的iterator,但是优化了类型擦除时的性能。只要迭代器不是太大,就可以避免内存分配操作--这可以单独写一篇技术文章。

    .STL一大让人诟病的地方是,只有迭代器对,而没有提供封装(最多用pair,但没有与之关联的算法支持),要知道C++可没法返回一对迭代器的。所以,我用range包裹一对迭代器,这在返回一个范围时很有用。但是现在想起来,我对range的设计仍然不够好,这在配合内存读写时特别明显。我的range就是定位成一个半开半闭的迭代区间,这个有时过于抽象。当我看到GoLang的slice是,我才知道我错在哪里,并且应该如何弥补。go的slice还有个额外的属性capacity,这在复制数据到一个buffer时非常有用,slice的capacity可以允许安全地越尾写出数据。我应该写一个slice,用于内存操作相关接口的,而不是直接用range。

    .String是非常重要的一种数据类型。STL的string虽然够强大,但是问题相当的多。借鉴Java,我设计了三个class: range_string,string,和string_builder,这是三个模板类。range_string实际上是一个迭代器对,但是保证其指向内存连续的区间。string则是一个immutable的实现,因此使用了引用计数的优化。string_builder的内部则完全类似STL string了。然而这里还是有两个重要的优化没有做。一是string pool。对于string,启用string pool有可能极大地降低内存使用,和优化字符串比较性能。但是启用这种pool机制必须是用户可控的和安全的。另一个是string_builder赋值给string,如果两者的内部数据结构是一样的,就可以在某些情况下move而不是copy。但是,我想不出在move时如何解决string_builder额外浪费的内存,或者如何评估这种浪费是划算的。range_string的本意是避免无谓的字符串复制,但是我没能做好这一点,在一些应该使用range_string的地方使用了string,至今没有纠正。完成string后,又写了utf8和uri编解码的算法。为了支持本地化,ascii字符串显然是不行的,但是我并没有打算使用传统的宽字符。我以前就注意到,如果程序使用utf8作为字符串编码,几乎是完全够用的--除了下标访问。而我因为已经习惯于使用迭代器风格,发现极少需要使用下标。因此,用utf8作为字符串编码是合适的,而且不需要两套字符串类型。当然,支持从utf8到ucs的转换是必要的,这就解决了偶尔需要的下标访问问题--以性能为代价。当我看到Golang也采用同样的策略时,更坚定了我使用utf8的信心。后来的经验证明,这是明智的选择,几乎所有的STL算法都可照常使用,和第三方库的交互性也更好了。

    .Archive是用来做io的。延续iterator的经验和正交分解原则,我把读写访问和seek分开了。数据访问有reader、writer。seek的接口有sequence(single pass),forward,和random。sequence实际意味着不能seek到一个新位置,比如标准输出。forward可以用于管道或socket,random则可用于文件。一方面,我让seek相关三个接口支持必要的查询,如当前offset,size信息(可能返回表示未知的值)。另一方面,尽可能简化,比如seek方法的定义:
        virtual long_size_t seek(long_size_t offset) = 0
在我看来,传统的fseek提供起点位置完全是罗嗦的。

    接口的正交分解也不是没有代价的。C++的多重继承固然很容易组合多个接口,但是缺乏从多个接口中选择子集的能力。例如,我可能有一个实现了reader,writer,和random的archive类,还有一个函数接受一个指针,指针指向一个具有reader和forward接口的对象。C++中没有一种简易的机制让我描述该指针的类型(archive<reader, forward>* p),并容易地从archive类的对象转换过来--我需要的是golang中的interface机制--和C++中曾经有人提议的dynamic concept map类似。最近,我终于在C++11中模拟了这个机制,工作得还不错。但是当时还是决定采取保守做法,利用dynamic_cast做了个blob的接口设计(blob接口是C++所反对的。STL的char_traits, iterator_traits都是blob设计。C++11中的type_traits则不是blob的,是C++鼓励的设计方式。)来做接口查询。这个设计的好处是简单,可行。坏处是缺乏弹性,僵化,丑陋。现在看来,我当时的决策太保守,应该更勇敢一些。这种设计也带来一些恶果。

    Archive最典型的实现当然是读写文件。为了性能,实现采用的是mmap方式。reader/writer的数据读写接口都和传统的read/write本质上是一样的,都是复制数据到buffer或相反,不过是形式稍有不同。既然采用mmap方式,那就可以直接返回一个地址指针了。因此,我又实现了const_view和view, reader/writer可以返回要求的const_view/view。这就避免了额外的内存复制。但是,不是所有的archive实现都能支持view的,所以又提供了一个viewable的方法去查询archive的能力。我后来发现,view应该被看作是一种数据访问方式,而不是reader/writer的另一个方法。如果我把view分离出来,reader/writer就不必提供viewable,并且几乎减少一半的方法,而且,很多不能支持view的实现就不必写好几个空的实现--只要不支持view接口就行了。可是如果分离view,这就要在那个blob的接口中增加新方法。受blob设计的阻碍,我做了错误的判断和决定,以致我在不同的派生类中重复空的readable和view_rd方法。
struct AIO_INTERFACE reader
{
     typedef buffer<byte>::iterator iterator;
     virtual iterator read(const range<iterator>& buf) = 0;
     virtual bool readable() const = 0;
     virtual const_view view_rd(ext_heap::handle h) const = 0;
protected:
     virtual ~reader() = 0;
};

    Archive的实现类包括:基于文件,基于一个可交换内存到磁盘的heap,基于一个给定的buffer对象,共三组。还有一些adaptor。虽然数量众多,但是因为接口简单,每个实现也都很简单,总共也就800行代码。为了避免跨平台的麻烦,我是用boost.interprocess.file_map来实现文件archive的,这个file_map对windows平台的路径支持有问题,不支持wchar_t,只有mbcs。我不得不给它打了个patch,我看了最近的boost,好像到1.51也还有这个问题。还好,这个patch很容易打。

    .VFS包含一个RootFs类和IVfs接口。之所以区分RootFs和IVfs是受Linux的启发,Linux也是区分两者的。RootFs只用来mount(unmount) IVfs,以及相关的查询功能。IVfs提供常见的目录和文件操作--文件操作不包含操作文件内部数据,那是archive干的事。包含三个实现,一个基于本地文件系统,一个基于内存的,另一个是基于zip格式的archive的。在ADP和Protein中,我受够了在不同平台上对文件路径的处理。受boost.filesystem的启发(进了TR2,但是很奇怪,居然没进C++11,这是我认为非常漂亮的一个库,特别是把路径操作抽象成纯粹的字符串算法非常出色。只是要编译这一点比较糟糕),我决定在整个息壤中,采用同一的文件路径格式,也就是unix风格的路径。通过契约约定,息壤只会接受和产生息壤风格的路径(除了两个专用于转换路径格式的函数),在所有和平台API交互处做路径格式转换。VFS也遵循这一约定。后来证明这是明智的做法,消除了所有ADP和Protein中出现的此类混乱和罗嗦的代码。

    对于采用utf8和单一路径格式这样的决定,并非都是模仿其他的范本作出的决定。软件设计通常应该要尽量推迟决策,这样会比较有灵活性。但是Ken Thompson说过:你总是要在某个地方hardcode。这在golang中很容易感受到这种设计哲学。一味地推迟决策也容易带来额外的复杂性。当然,后来在一些优秀的系统中看到相同的设计决策,就会额外地得到鼓舞。

    .类型系统基本上是模仿C++的struct。因为要考虑到效率,空间布局和时间性能都是必须要非常紧凑。类型系统有基础类型和一个组合扩展机制构成。对基础类型定义了size,alignment,还有一个pod属性用来配合做对象初始化和销毁。对所有的类型,都定义了公共的方法,包括构造,析构,赋值,序列化,和一个typeinfo的handle,用来辅助检查绑定到C++对象时的类型是否正确。有了size和alignment,组合机制就可以像C++的struct一样,计算出每个数据成员的偏移量,也就可以直接访问了。组合机制还支持了多继承,这个纯粹是不想引起C++程序员的惊讶,所以没有坚定去掉的决心,后来实际上根本也没有用到过。从内存布局来说,继承和成员变量的方式并无区别。

        类型系统还支持参数化类型机制,有点像运行时的C++template。这样,对于数组,就可以定义为一个带参数的基础类型,用到的时候只要和具体类型绑定就可以了。如果需要一个新的复杂数据结构,完全可以用C++写一个,然后作为基础类型加到类型系统中。这是很有用的,因为类型系统并不是ADT,不能随意增加方法,C++则很方便加方法。如果不需要增加复杂的算法,也可以直接在类型系统中进行扩展。两种方式在息壤都有使用。

        类型系统处理的不好的一个地方是,其上有哪些方法是预定义好的,很难扩展。除了构造,赋值这种基本操作外,也可能需要比较,hash这样的方法。Java中的做法是在Object中一股脑加进去,这是我不喜欢的,因为不是所有的数据类型都一定可比,即使可比语义也未必也一样。我希望的是有一种自由的方式给某个类型附加任意多的方法。方法表似乎是出路,但是hash表的代价太高,我认为不可接受,数组的排序问题怎么解决以及查询机制又是个问题。怎么做最好我到现在也没想到,只能留着慢慢来。但不管怎么说,即使扩展方法的能力欠缺,但是扩展数据类型的能力也是意义重大的。在可以预见的未来,类型扩展机制够用了。这种用基础数据类型装配出新的类型,新的类型有和基础类型一样成为装配更新的类型的材料,仿佛可以自我增殖一样。正是这种特性让我联想到息壤,传说中可以生长的泥土,我也因此用息壤来作为项目的名称。

        考虑到类型定义可能是一段手写的文本描述,那么手写的简洁性是必须考虑的,另外,还要解决类型名称冲突。为此引入了别名机制(alias),和命名空间(namespace)。有了namespace,就可以用完全限定名称来唯一标识一个类型。这个类型系统在我看来还有一大缺陷,是参数化机制引起的。在C++中,Array<int>这样一个类型的名字是用某种拼接机制产生的字符串,参数很多或这嵌套很深时会导致很长的类型名字,这是极其让人讨厌的。另外,给两次Array<int>产生两个类型也很让人蛋疼。如果每个类型都有一个确定唯一的id那就好了,但如何产生这个唯一的id很是头疼。我想过对类型的成员、属性做sha1或md5来产生id,但是如何处理碰撞呢?虽然碰撞的概率接近于0,但毕竟不是0啊。这个问题也是到现在也没有去解决的。

        还有另一个和实现有关的问题也很让我闹心。息壤有一个运行时系统管理所有的类型,类型之间显然会存在依赖关系。那么我需要在很大程度上保证用户不会建出一个错误的类型来,也不能让一个建到一半、未完工的类型保留在系统中。当时我还希望把类型的描述通过一个数据结构提供给息壤,然后息壤一次性处理这个数据结构,如果失败,就要指出数据结构中错在哪里,并且对系统状态不能产生影响。看上去这个设计非常简单,但是实现起来却变得困难重重。我反复实现了三次,每次都不能让我满意,而且使用的时候,设置那个数据结构也很罗嗦繁琐。没办法,我决定先放弃那个数据结构和错误报告机制,先以最简单的方式完成功能再说。但异常安全是不能放弃的,因此,我写了一个transaction类,负责完成所有的数据building的工作,并在失败时回滚撤销。这样一来,transaction类又变成了一个大杂烩式的肥大类,充满了各种操作。但是transaction也还是有价值的,它把复杂的构建充分分解成了一系列充分小的操作步骤,这样每个步骤的调用点就只会依赖类型描述的一个数据点--不管这个描述是数据结构还是一种语言文本。这种推卸是可行的。我后来在写第一个demo时,很容易就在解析器中做了错误报告,根本无需依赖息壤。

    之后,transaction改进的方案似乎一下子就自动浮出水面了。第一个transaction依赖息壤运行时的问题。在实现transaction的时候发现,transaction其实只在最后提交的时候才需要和运行时打交道,也就是说,我很容易创建一个完整的,但是没有登记到运行系统的类型。这是测试需要的,意味着我可以把一个类型对象隔离到更小的范围了,而无需像最初那样,为了得到一个测试对象,满世界都要被惊醒。另一个是transaction的那些方法很容分组归类,自然地,应该被拆分成多个class。自然地,先用builder模式创建对象,再在事务中提交类型对象到运行时就水到渠成了。transaction被分解成了若干个互不相干的builder,彻底解决了长期让我难受的问题。

    虽然只能在工作和陪孩子之余才能有时间写代码,也想尽快地把息壤弄完撒手,但我还是给代码定下了--或者说坚持--质量优先的原则。在设计方面,主要坚持如下原则:
    1.Design By Contract
    2.基于组件
    3.方法/接口正交分解
    4.接口最小化
    5.区分“方法”和“查询”
    6.“方法”的效果是可观察的(通过“查询”观察)
    7.异常安全
    8.支持设计演化
当然,还有一些广为人知的原则是必然坚持的:KISS,DIP(依赖倒置),DRP(不要重复自己,Raymond称为SPOT,真理的单点性原则),SRP(单一职责)。MVC也是也别注意的。

    对于质量实际上体现在两方面。一是设计质量,二是实现质量,当然两者也是相互依存的。特别是设计质量,很大程度上依赖于那些设计原则坚持了多少,另外单元测试也非常重要。成熟的程序员应该主要把写单元测试看作改进设计的手段,检测实现那只是副产品。对于实现质量,我认为单元测试是最经济高效的办法--当然,设计必须支持测试。很多开发人员对测试没有清晰的认识,分不清各种测试的职责和价值,更不要提写容易测试的软件了。测什么?怎么测?以及什么样的系统才是容易测试的?这不仅是测试需要关心的,开发一样要关心。对于开发来说:1.测的是接口,接口除了函数原型外,就是行为。而行为可以通过输入、输出的契约来描述--虽然可能有些契约无法用编程语言描述,甚至很难形式化。把对行为的关注转移到对契约和参数的关注上来,就是从面向实现编程转移到面向接口上来。这种转变实际上关乎到测试的能行性。2.给定被测试对象一个输入数据,然后观测系统的状态,检查是否符合预期。如果遵循设计原则中的5和6两条,那么这种测试方法就是可行的。3.符合2、3、4设计原则的才是容易测的。测试的一个基本要求就是能将测试对象孤立甚至是隔绝出来。如果给测试一个对象一个输入,牵一发而动全身,整个系统,无数的状态改变,又如何用有限的资源完成测试呢?一个理想的系统,测试代码量应该和接口函数的数量之间保持线性关系。另外,创建或初始化测试对象,或者说测试的数据准备工作应该非常便捷且廉价。我对息壤中比较基础的,重用很多的部分测得比较完整,因此也几乎没出过什么问题。

    另一个重要的问题是性能。我认为很多人没分清性能和优化。软件的整体性能是很难通过优化解决的(老实说,我从未见过成功案例),优化只能改善局部性能问题--通常以复杂性为代价。性能必须是从初始设计开始就要关注并且持之以恒地维持的。我认为通常有两类性能问题,一是追求单位时间的最大吞吐量,另一个是追求响应速度。两者都很常见,是截然不同的两类问题。大部分情况下,性能问题的解决方案能同时改善这两类问题,典型的例子是算法改进。但是有时也会冲突,例如,为了解决一个响应时间的性能问题,有时可以采用并行计算来加速,或者把计算的某些部分延迟到响应之后。这些措施都存在额外的开销,这将降低系统的最大吞吐量。

    我非常厌恶ADP中那种错误的性能观点-比如喜欢用memcpy,原生数组等等-但仍然注重性能。首先是在接口设计上就要充分考虑,避免抽象惩罚,archive中view的设计就是一例。借助于C++的能力,让息壤的抽象惩罚主要表现在编译期,而非运行期。解决性能问题要靠设计,要落实到数据结构和算法上。Brooks曾经说过大意是这样的话:你藏起数据表,给我看流程图,我还是不知道你要说什么;给我看你的数据表,啊,我不再需要你的流程图了。另一个忘了出处的话是:算法是流动的数据结构,数据结构是凝固的算法。还是在ADP项目的时候--当时有人在鼓吹算法的力量,这当然也没错--我就曾经对两个同事说过:如果你不曾纠结于数据结构的设计和选择,那你还算不上是真正的程序员。我说这话是因为看到太多的人,太多的代码,肆无忌惮地往class里面塞成员变量,有时候,仅仅是用来代替传参数给某个成员函数,还有的时候则仅仅是当作局部变量用。另外,一个状态或者属性,反复地出现在多个class中,而对可能造成的不一致性视而不见。怪不得有人说学过数据库设计的人,做出来的OO要好的多。我开始赞成所有使用数据抽象技术的程序员都应该接受范式的思想。Protein在这方面很糟糕--虽然还算不上最糟糕的。

    接口设计也会关乎性能问题。不止一个项目中看到链表类提供下标访问,全然不顾这是个O(N)算法,用户用这样的链表,很容易就会不经意间写出O(N^2)的算法来。我亲眼目睹过许多这样O(N^2)的代码。少就是多,多反而会坏事。任何时候,当不得不选一个降阶的算法时,都不能轻易屈服,你今天对数据规模所作的种种假设,明天就会过时。任何时候都不要以“不成熟的优化是万恶之源”来反驳算法复杂度阶的变化(常数项不予考虑)。我在写息壤的过程中持续关注性能的方式就是关注复杂度。我的实践也表明,关注复杂度可以解决绝大多数性能问题,项目规模越大,复杂度就越重要。我认为,任何时候,改进复杂度的阶都不能算是优化,而是修Bug--设计或实现的Bug。把具体的实现问题正确归类到已知的算法的能力也是重要的,而不是随手写。比如我见到很多合并两个已序的序列(例如合并两个std::map到一个数组中)实现为O(NLgN)而不是O(n)的,在已序序列上遍历而不是二分查找的,不一而足--这种做法真的该打屁股。不要说C++算法库已经提供了可以直接调用的库函数,就是没有现成的函数,也应该自己按照算法写一遍。也许我孤陋寡闻,网上很多人喜欢谈论和关注的算法、数据结构,大多是特定于某个领域的,在某个点用过了,也就用过了。正经是排序,查找,二分,归并这些基本的算法像吃饭喝水一样,每天都要用到。数据结构也是,数组,链表,二叉树(C++主要就是map,set),hash表这些才是每天要用的。就连数组这样基本的东西,有些程序员仍然对其性能,内存,错误处理等等不甚了了,真是让人大跌眼镜。

    2011年,在Protein为了改进性能和支持新特性,我主要参与两个部分。一个是文件存储格式做了改变,虽然还是使用ADP保存到OPC格式,但是主要的数据不再保存为XML,而是二进制。另一个是引入了Schema的概念。这两部分都带来了大量的不稳定性。Protein的新格式保存出去的数据格式是按照Schema的描述来的,并没有自描述部分。Schema因为没有版本,因此遇到版本升级、修bug、同名合并等问题,其内容并不是稳定的。一旦Schema和二进制数据不精确匹配,就会导致在加载数据是崩溃。这个问题我们也是在一开始就提出来过,也是一样被告知不会发生,接下来的故事就是历史的重复,而且因为一旦出问题问题的后果特别严重,特别难定位问题原因。幸好,大部分时候不是我来修这样的bug。

    到6月底,当年的主要开发工作结束,然后就是集成。7,8月份,在和一款产品集成时(不是全新集成,只是程度更深),出了严重的性能问题。产品那边给出的接受标准非常高,只能是两边共同做优化。有段时间每天就是profiling,找热点,调整,等产品的反馈,然后继续。我做了两个优化,一个是修改FBX内部实现,对性能改善效果非常显著,但是读代码和修改时相当痛苦。另一个是改进Protein的引用计数机制,读代码那就不是痛苦了,而是想死的心都有了。改完了引用计数,还要改许多hack了引用计数的地方。总体上,我认为产品的优化对最后性能的改善作用更大些,但Protein确实也对性能做了巨大改进。

    到5,6月份,我把息壤也整理的差不多了,代码有2万多行,仅仅是ADP的十分之一。我没有精力也没有兴趣以息壤为基础重新实现一个Protein,但是又不甘心就此让息壤消失。于是写了一个demo,和一个很简陋的PPT,作为知识共享,介绍给了公司的同事,想以此作为一个了结。当时Olivia同学一再鼓动我,让我把息壤的文档弄好,推广出去,但我已经毫无动力了。

    当时公司决定全面拥抱云计算,中国这边的研发当然也是想方设法做一些原型,来被美国那边看重。我当时被要求做一个材质共享的原型。所谓共享,就是在一个程序可以从服务器上在线获取材质的数据和图像,如果程序新建或修改材质,保存结果到服务器上,另一台机器上的程序就可以浏览到最新的数据,直接使用。这个过程要求不需要显式的下载过程,必须是和使用本地材质库差不多的方式。服务器则使用公司自己做的一个云存储的服务。最后给了我们大概两三个星期的时间。

    显然Protein的数据包格式不能满足要求,而两三周内在原来的ADP上改几乎是个不可能的任务。因为是原型,我们只想尽快把东西做出来。如果不考虑ADP,Protein只需在IO部分,把存出去的数据重定向到网络服务器就可以了。息壤的VFS来做这个任务是非常简单的。于是,我决定用息壤来做。首先是花了一周把息壤从Linux移植到windows。另一个同时包装了一下云存储的几个功能给VFS用,然后我就给这个云服务实现了一个VFS,最后修改了一下界面。结果,那个云存储的速度慢到无法忍受。于是决定自己搭一个本地服务器,不用云存储。这时,息壤的表现非常棒,虽然加了几个小时班,还是在一天之内就弄完了。性能不是问题,用来演示是足够了。

    我本以为息壤的命运也就到此为止了,但是没想到,事情又有了转机。
  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 5
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值