深度探索C++对象模型 学习笔记 第三章 Data语意学(2)

加上多态(Adding Polymorphism)

如果我们希望独立于点是 Point2d 还是 Point3d 实例来对点进行操作,就需要在类的继承体系中提供一个虚函数接口。下面我们来看看引入虚函数之后会发生怎样的变化:

只有在打算以多态的方式操作二维点和三维点时------例如编写如下代码------将虚接口引入我们的设计才有意义:

在这样的函数中,p1 和 p2 可能是二维点,也可能是三维点。这是之前所有设计都不曾支持的功能。当然,这种灵活性正是面向对象编程的核心所在。然而,支持这种灵活性确实会给我们的 Point2d 类带来一些空间和访问时间上的开销:

1.引入虚函数表:每个 Point2d 类都会关联一个虚函数表,用于存放它所声明的每个虚函数的地址。这个表的大小通常是所声明的虚函数个数,再加上额外的一到两个槽位,用于支持运行时类型识别(RTTI)。

2.在每个类对象中引入 vptr 指针:vptr 为对象提供了运行时的链接,使其能够高效地找到对应的虚函数表。

3.对构造函数进行扩充:需要初始化对象的 vptr,使其指向该类的虚函数表。根据编译器优化的激进程度不同,这可能意味着在派生类以及每个基类的构造函数中都要重新设置 vptr(这一点将在第5章中详细讨论)。

4.对析构函数进行扩充:需要将 vptr 重新设置为当前子类的虚函数表(因为在派生类的析构函数中,vptr 很可能已经被设置为指向派生类的虚函数表了。请记住,析构函数的调用顺序是相反的:先调用派生类,再调用基类)。

这些开销的实际影响,取决于程序中 Point2d 对象的数量、生命周期,以及通过多态方式使用这些对象所带来的收益。如果一个应用明确知道自己对点的使用仅限于二维点或三维点中的一种(而非两种混合使用),那么这种多态设计所带来的开销就可能变得难以接受。

下面是我们的新版 Point3d 派生类定义:

尽管该类的声明语法看起来没有任何变化,但其内在的一切都已截然不同:两个 z() 成员函数以及 operator+=() 运算符都变成了虚函数。每个 Point3d 类对象中都包含一个额外的 vptr 成员对象(这个 vptr 是从 Point2d 继承而来的)。此外,还存在一张专属于 Point3d 的虚函数表。而对每个虚成员函数的调用方式也变得更为复杂(这部分内容将在第4章中详细讨论)。

目前,在C++编译器社区中有一个正在争论的话题:vptr 究竟应该放在类对象中的哪个位置最好。在最初的 cfront 实现中,vptr 被放置在类对象的末尾。目的是为了支持以下继承模式:

将 vptr 放置在类对象的末尾,可以保持基类(即 C 结构体)原有的对象布局,从而使得该类对象能够直接用于 C 代码中。许多人都认为,这种继承方式在 C++ 刚问世时比现在更为常见。

到了 2.0 版本之后,随着对多重继承和抽象基类的支持被加入,以及面向对象编程范式的普遍流行,一些编译器实现开始将 vptr 放置在类对象的起始位置(例如,领导微软最初 C++ 编译器开发工作的 Martin O'Riordan 就曾有力地论证过这种实现模型)。图 3.2(b) 展示了这一布局方式:

将 vptr 放置在类对象的起始位置,在多重继承场景下通过指向类成员的指针调用某些虚函数时效率更高(详见第4.4节)。否则,不仅需要在运行时获取指向类对象起始地址的偏移量,还需要额外获取该类中 vptr 所在位置的偏移量。然而,这种做法的代价是失去了与 C 语言的互操作性。这个代价有多大呢?有多少程序会从一个 C 语言的结构体派生出多态的类呢?目前还没有任何经验数据能够支持其中任何一种立场。

图3.3展示了引入虚函数之后 Point2d 和 Point3d 的继承布局(需要说明的是:该图中 vptr 被放置在基类的末尾位置):

多重继承(Multiple Inheritance)

单继承在继承体系中提供了一种"天然的"多态性,尤其体现在基类与派生类类型之间的转换上。请看图3.1(b)、图3.2(a)或图3.3,你会发现基类对象和派生类对象的起始地址是相同的。它们的区别仅在于派生类对象扩展了其非静态数据成员的长度。像下面这样的赋值操作:

将派生类对象的地址赋给基类指针(或引用)时,无论继承层次有多深,都不需要编译器做任何额外的干预或地址调整。这种转换是"自然而然"发生的,从这个意义上说,它提供了最优的运行时效率。

从图3.2(b)可以看出,将 vptr 放置在类对象的起始位置,在一种特殊情况下会破坏单继承的"自然多态性"------即基类没有虚函数,而派生类包含虚函数。此时,将派生类对象转换为基类类型时,需要编译器的干预:必须根据 vptr 的大小对要赋值的地址进行相应调整。而在多重继承和虚继承的情形下,编译器干预的必要性则更为显著。

多重继承既不像单继承那样行为规整,也不那么容易建模。其复杂性源于派生类与第二个及后续基类子对象之间那种"非自然"的关系。例如,考虑下面这个多重继承的派生类 Vertex3d:


多重继承带来的问题,主要集中体现在派生类对象与它的第二个(以及后续)基类子对象之间的转换上。这些转换可能以两种形式出现:

1.直接的转换,例如:

2.通过虚函数机制的间接支持(关于虚函数调用所引发的问题,将在第4.2节中详细讨论)。

多重继承中,将派生类对象的地址赋给其最左边(即第一个)基类的指针时,其处理方式与单继承完全相同------因为两者指向的是同一个起始地址。这种转换的开销仅仅是地址的简单赋值,下展示了多重继承的内存布局:

然而,当需要将派生类对象的地址赋给第二个或后续基类的指针时,情况就不同了:该地址必须通过加上(或减去,在向下转型的情况下)中间基类子对象的大小来进行调整。例如,考虑以下代码:

那么以下赋值操作:

需要编译器进行如下的地址转换(伪C++代码):

而以下赋值操作:

则都只需要简单地复制地址即可,无需任何偏移调整------因为 Point2d 和 Point3d 都位于最左边的基类位置(或者与 Vertex3d 起始地址相同)。

现在考虑使用指针的情况:

对于以下赋值情况:

并不能简单地转换为如下形式:

因为,如果 p3vd 被设为 0,那么按照之前的简单加法,pv 就会得到一个值为 sizeof(Point3d) 的非零地址,这显然是不正确的。因此,对于指针而言,内部转换必须加入一个条件判断,如下所示:

而对于引用的转换,则无需处理可能出现的 0 值,因为 C++ 规定引用永远不可能指向空对象(即不存在"空引用")。因此,引用转换时可以直接进行地址偏移,不需要额外的条件测试。

C++ 标准并没有规定 Vertex3d 中 Point3d 和 Vertex 这两个基类的具体排列顺序。在最初的 cfront 实现中,它们总是按照声明的顺序来放置。因此,在 cfront 中,一个 Vertex3d 对象的内存布局依次包含:Point3d 子对象(而 Point3d 本身又包含一个 Point2d 子对象),紧接着是 Vertex 子对象,最后才是 Vertex3d 自己新增的部分。事实上,这仍然是当前所有编译器对多重基类进行布局的通用做法(虚继承的情况除外)。

不过,某些编译器(例如 MetaWare 编译器)会进行一种优化:如果第二个(或后续)基类声明了虚函数,而第一个基类没有,那么它们就会交换多个基类的排列顺序。这种重新排序的好处是,可以避免在派生类对象中额外生成一个 vptr(可能指的是把派生类的vptr放到基类的虚表里,而且只有第一个基类有虚函数时才这样做,C++标准只规定了虚函数的语义,并未规定vptr的实现细节,原文这一点了解即可)。但是,不同编译器对这一优化的重要性看法并不统一,而且这种优化目前(至少到现在)并不普遍。

访问第二个或后续基类的数据成员会有额外开销吗?答案是不会。在编译阶段,成员的位置就已经被确定下来。因此,无论访问该成员时使用的是指针、引用还是对象本身,其访问方式都如同单继承一样简单------仅需计算一个固定的偏移量即可。这种机制确保了多重继承中的成员访问效率与单继承保持一致,不会带来额外的性能开销。

虚拟继承(Virtual Inheritance)

多重继承的一个语义副作用是需要支持一种共享子对象继承的形式。其经典示例是原始iostream库的实现:

istream 和 ostream 类各自都包含一个 ios 子对象。然而,在 iostream 的布局中,我们只需要一个 ios 子对象。语言层面的解决方案是引入虚拟继承:

尽管虚继承的语义看似复杂,但其在编译器中的实现被证明更为困难。在我们的iostream示例中,实现面临的挑战在于:需要找到一种合理高效的方法,将istream类和ostream类各自维护的ios子对象的两个实例合并为iostream类维护的单个实例,同时仍需保持基类与派生类对象指针(及引用)间的多态赋值能力。

通用实现方案如下。像istream这样包含一个或多个虚拟基类子对象的类,其内存布局被划分为两个区域:不变区域和共享区域。不变区域内的数据无论后续如何派生,其相对于对象起始地址的偏移量始终保持不变。因此,可以直接访问不变区域内的成员。共享区域则代表虚拟基类子对象,该区域内数据的偏移量会随每次派生而浮动。因此,共享区域内的成员需要通过间接方式访问。不同编译器实现方案的主要差异就在于这种间接访问的方法。以下面代码为例展示了三种主流策略,以下是一个虚拟继承的Vertex3d类的层次结构,只展示了其中的数据部分:

通用的布局策略是:首先放置派生类中不变的部分(即派生类自身新增的非静态数据成员),然后再构建共享的部分(即虚基类子对象)。

然而,还有一个问题尚未解决:实现层面如何访问类的共享部分?在最初的 cfront 实现中,编译器会在每个派生类对象中插入一个指向每个虚基类的指针。对继承而来的虚基类成员的访问,就是通过这个相关的指针间接完成的。例如,考虑以下 Point3d 的运算符实现:

会被内部转换为类似下面的伪代码(伪C++代码):

而派生类与基类之间的转换,例如:

在 cfront 的实现模型下,则会被转换为:

这种实现模型存在两个主要的缺陷:

1.每个虚基类都会在类对象中引入一个额外的指针。理想情况下,我们期望类对象的空间开销是一个常量,与继承体系中虚基类的数量无关------你可以思考一下,如何才能实现这一目标。

2.虚继承链越长,间接访问的层级也越深。也就是说,如果存在三层虚继承,就需要通过三个虚基类指针逐级间接访问。理想情况下,我们希望无论虚继承的深度如何,访问时间都保持常量。

MetaWare 以及其他仍在使用 cfront 原始实现模型的编译器,通过将嵌套的虚基类指针全部提升(即复制)到派生类对象中,解决了第二个问题------即访问时间随虚继承深度增加而增加的问题。这种方法虽然以重复存储嵌套的虚基类指针为代价,但换来了恒定时间的访问性能。MetaWare 还提供了一个编译时开关,允许程序员自行选择是否生成这些重复的指针。图 3.5(a) 展示了这种指向基类的指针实现模型:

针对第一个问题(每个虚基类都引入一个额外指针),业界主要有两种通用的解决方案。微软的编译器引入了虚基类表(virtual base class table)的概念:每个包含一个或多个虚基类的类对象中,会插入一个指向虚基类表的指针,而各个虚基类所对应的指针则被统一存放在这张表中。

尽管这种解决方案已经存在多年,但据我所知,目前还没有其他编译器采用它(这可能是因为微软对其虚函数实现申请了专利,从而实际上禁止了其他编译器的使用)。

第一个问题的第二种解决方案------也是 Bjarne(至少在我与他一起从事 Foundation 项目期间)所偏爱的方案------是不直接存储虚基类的地址,而是在虚函数表中存放虚基类在派生类对象中的偏移量,图 3.5(b) 展示了这种基类偏移实现模型:

我曾在 Foundation 研究项目中实现了这一方案,将虚基类条目与虚函数条目交织在一起。在较新的 Sun 编译器中,虚函数表同时支持正索引和负索引:正索引(与之前一样)用于访问虚函数集;负索引则用于获取虚基类的偏移量。在这种策略下,Point3d 的加法运算符会被翻译成如下通用形式(为保持可读性,省略了类型转换,也未展示更高效的地址预计算):

尽管在这种策略下,访问继承而来的成员的开销会更高一些,但这种开销仅局限于实际使用该成员的地方。而派生类与基类实例之间的转换,例如:

在该实现模型下,会被转换为如下形式(伪C++代码):

以上所述均为具体的实现模型,并非 C++ 标准所强制要求的。它们分别以各自的方式解决了同一个核心问题:如何访问一个位置可能随每次派生而变化的共享子对象。由于支持虚基类所带来的开销和复杂性,不同的编译器实现之间存在一定差异,并且这种实现很可能会随着时间推移而继续演进。

通过非多态类对象(即具体的类对象,而非指针或引用)来访问继承而来的虚基类成员时,例如:

编译器可以将其优化为直接的成员访问,这与通过对象调用虚函数时能够在编译期解析是类似的。因为对象的类型在程序的两次访问之间不会改变,所以虚基类子对象位置可能变化的问题在这种情况下并不存在。

一般而言,虚基类最有效的用法是将其作为不含任何数据成员的抽象虚基类。

3.5 对象成员的效率(Object Member Efficiency)

接下来的这组测试,旨在衡量使用聚合、封装和继承所带来的额外开销。所有测试的基准都是对独立局部变量进行赋值、加法和减法等操作时的访问成本:

实际执行的表达式循环会迭代 1000 万次,其形式如下所示(当然,具体的语法会随着坐标点的表示方式不同而有所变化):

第一个测试用于与使用独立变量的情况进行对比,其场景是使用一个包含三个 float 类型元素的局部数组(代码中,pA和pB分别是两个包含三个元素的数组,数组下标为x、y、z,这三个下标是枚举类型的,默认x是0、y是1、z是2):

第二个测试将同质的数组元素转换为一个 C 结构体数据抽象,其中包含三个具有明确名称的 float 成员:x、y 和 z:

在抽象的阶梯上,下一步便是引入数据封装以及内联访问函数的使用。此时,点的表示方式被封装为一个独立的 Point3d 类。我尝试了两种形式的访问函数:

1.第一种:定义一个返回引用的内联函数,使其能够出现在赋值运算符的两侧:

对每个坐标元素的实际访问,其代码形式如下所示:

2.第二种:提供了一对get和set函数:

此时,每个坐标值的赋值操作则呈现为如下形式:

表3.1列出了两种编译器运行测试的结果(仅当两种编译器的性能表现存在显著差异时,我才会将它们的时间数据分开列出):

就实际程序性能而言,这里的关键在于:当开启优化后,封装以及使用内联访问函数并不会带来任何运行时的性能开销。

我很好奇,为什么在 CC 编译器下,数组访问的速度几乎比 NCC 慢了一倍?尤其是,这里的数组访问仅仅涉及 C 语言层面的数组操作,并没有用到任何"复杂"的 C++ 特性。一位代码生成方面的专家将这种异常现象归结为"代码生成中的某种特殊行为......仅在某些特定编译器中才会出现"。或许事实确实如此,但问题在于,这个编译器恰好是我目前用来开发软件的那个。如果你愿意,不妨叫我"好奇的乔治"。如果你对此不感兴趣,可以直接跳过接下来的几段内容。

在下面的汇编输出中,l.s 表示加载单精度浮点值;s.s 表示存储单精度浮点值;sub.s 表示两个单精度浮点数相减。对于两个编译器生成的汇编代码,它们都执行了相同的操作:加载两个值,相减,然后存储结果。但在效率较低的 CC 输出中,每个局部变量的地址都被计算出来并存入寄存器(addu 表示无符号加法):

在 NCC 生成的汇编代码中,加载步骤直接计算出地址(无需额外的地址加法指令):

如果局部变量需要被多次访问,那么 CC 编译器的策略可能会更高效。然而,对于单次访问来说,那种将变量地址预先放入寄存器的做法(虽然看似合理)却会显著增加表达式的开销。无论如何,一旦开启优化,两种编译器生成的代码序列最终都会被转换为相同的形式:循环内的所有操作都直接在寄存器中的值上完成。

一个显而易见的结论是:如果不开启优化,几乎不可能准确推测程序的性能特征,因为代码很可能受制于"特定编译器独有的代码生成特性"。因此,在试图通过源代码级别的"优化"来加速程序之前,务必要进行实际的性能测量,而不是仅仅依赖猜测和直觉。

在接下来的测试中,我首先引入了 Point 抽象的三层单继承表示,然后又引入了虚继承表示。我分别测试了直接访问(即对象.成员的方式访问)和内联访问(即对象.x(),x方法直接返回成员x的值)两种方式(多重继承与这个模型不太契合,因此我决定暂不测试)。整个继承层次的基本结构如下:

在虚继承的测试中,我设置了两种情况:

1.单层虚继承:Point2d 虚继承自 Point1d。

2.两层虚继承:在单层虚继承的基础上,Point3d 再虚继承自 Point2d。

表 3.2 列出了这两种编译器运行测试的结果(同样,只有当两种编译器的性能表现存在显著差异时,我才会将它们的时间数据分开列出):

单继承按理说不会影响测试的性能表现,因为所有成员在派生类对象中是连续存储的,并且它们的偏移量在编译时就已经确定。正如所料,测试结果与独立抽象数据类型的情况完全一致(多重继承下应该也是如此,不过我没有亲自验证这一点)。

再次强调一个值得注意的现象:在关闭优化的情况下,那些凭直觉认为性能应该相同的操作(比如直接访问成员 vs. 通过内联函数访问),实际上内联函数的版本反而更慢。这里再次得到的教训是:关心效率的程序员必须实际测量程序的性能,而不能仅凭猜测和假设来推断。另外还需注意,优化器并非总能如预期般工作。我不止一次遇到过这样的情况:在开启优化时编译失败,而"正常"关闭优化时却能顺利通过编译。

虚继承下的性能表现令人失望:两种编译器都没能意识到,对继承而来的数据成员 pt1d::_x 的访问实际上是通过非多态类对象进行的,因此运行时根本不需要间接访问。尽管在编译时,该成员在两个 Point3d 对象中的位置就已经固定,但编译器依然生成了对 pt1d::_x 的间接访问代码(在两层虚继承的情况下,对 pt1d::_y 也是如此)。这种间接访问严重抑制了优化器将全部操作移至寄存器中执行的能力。不过,对于未优化的可执行程序而言,这种间接访问的影响并不显著。

3.6 指向Data Member的指针(Pointer to Data Members)

指向数据成员的指针是 C++ 语言中一个略显晦涩但却很有用的特性,尤其是在你需要探查类的底层成员布局时。例如,可以用它来判断 vptr 是位于类对象的起始位置还是末尾。另一个用途(在第 3.2 节中已经展示过)是确定类中不同访问段的排列顺序。正如我所说,这是一个虽然有些隐晦、但具有潜在实用价值的语言特性。

来看下面这个 Point3d 类的声明。它声明了一个虚函数、一个静态数据成员,以及三个坐标值:

每个 Point3d 类对象中的成员布局都包含三个坐标值,按 x、y、z 的顺序排列,此外还有一个 vptr(回想一下,静态数据成员 origin 是被提升到各个类对象之外的)。布局中唯一的实现相关方面就是 vptr 的放置位置。标准允许 vptr 被放置在对象内的任意位置:可以在开头、末尾,或者三个成员之间的任何地方。在实践中,所有实现都将其放在开头或末尾。

那么,取其中一个坐标成员的地址意味着什么?例如,下面的表达式应该产生什么值(我自己测试时,发现z是protected的,无法访问,我用的MSVC 编译器版本号: 1938)?

它将产生 z 坐标在类对象内的偏移量。至少,这个偏移量必须等于 x 和 y 成员的大小之和,因为标准要求同一个访问级别内的成员按照声明顺序排列。

然而,编译器可以自行决定将 vptr 放置在坐标成员之前、之间或之后。同样,在实践中,vptr 要么放在类对象的开头,要么放在末尾。在32位机器上,每个 float 占4字节,因此我们预期偏移量要么是8字节(如果中间没有 vptr 间隔),要么是12字节(如果有 vptr 间隔)------(在32位架构下,vptr 以及一般的指针都占4字节)。

但是,这个预期差了一个1------这可以说是 C 和 C++ 程序员历来容易犯的一种错误。实际上,三个坐标成员在类布局中的物理偏移量分别是:若 vptr 放在末尾,则为 0、4、8;若 vptr 放在开头,则为 4、8、12。然而,取成员地址所返回的值,总是会被加一。因此,实际得到的数值是 1、5、9 等。你能猜到 Bjarne 为什么要这样做吗(但我输出的内容没有加1,是4、8、12,我用的MSVC 编译器版本号: 1938)?

问题在于如何区分"不指向任何数据成员的指针"和"指向第一个数据成员的指针"。例如:

为了区分 p1 和 p2,每个实际成员的偏移值都被加1处理。因此,编译器(以及用户)在实际使用该值访问成员之前,都必须记住先减1。

基于现在对指向数据成员的指针的了解,我们就能很清楚地解释下面两种写法的区别:


取非静态数据成员的地址,得到的是该成员在类中的偏移量;而取绑定到具体类对象的数据成员的地址,得到的则是该成员在内存中的实际地址。表达式 &origin.z 的结果是将 z 的偏移量(减1后)加到 origin 的起始地址上(实际上,由于C++地址空间是从高地址向低地址方向扩张的,因此应该是用origin的地址减去z的偏移量加1)。返回值的类型是 float*,而不是 float Point3d::*,因为它指向的是一个具体的实例,这与取静态数据成员的地址非常相似。

在多重继承下,当我们将一个指向第二个(或后续)基类的成员指针与派生类对象绑定使用时,情况会变得复杂,因为需要额外加上一个偏移量才能正确访问。举个例子,假设有如下定义:

当 bmp 作为第一个参数传递给 func1() 时,必须根据 Base1 子对象的大小对其进行调整。否则,在 func1() 内部执行 pd->*dmp 时,将会错误地访问 Base1::val1,而不是程序员期望的 Base2::val2。针对这种情况,编译器会在内部进行如下转换:

然而,一般情况下,我们无法保证 bmp 不是空指针(即 0),因此还需要加上空指针检查:

指向成员的指针的效率

以下这组测试旨在衡量:在三维点的各种类表示形式下,使用指向成员的指针会带来多少额外开销。前两种情形都不涉及继承。

第一种情形:取绑定到具体对象成员的地址(普通指针)。例如,对点 pA 和 pB 的三个坐标成员取地址:

然后通过指针进行赋值、加法和减法操作:

第二种情形:取指向数据成员的指针(成员指针)。例如,指向 Point3d 类数据成员的指针:

然后使用指向成员的指针语法,将指针与具体对象 pA 和 pB 绑定:

回忆一下,在第3.5节中执行的该函数直接数据成员测试,在两个编译器上开启优化时平均用户时间为 0.80,关闭优化时为 1.42。下面这两项测试(指向上节的两种成员指针方式)的结果,连同直接数据访问的结果,一并列在表 3.3 中:

未优化时的结果符合预期------也就是说,通过绑定指针访问成员时多出来的一层间接操作,让执行时间增加了一倍以上。而改用成员指针访问,时间又近乎再翻一番。将数据成员指针绑定到类对象的过程,相当于在对象地址上增加一个"偏移量减1"的调整值。当然,更重要的一点是,优化器能够将这三种访问方式的性能拉平到同一水平,唯独NCC优化器表现异常(这里值得注意的是,NCC生成的可执行文件在开启优化后性能仍惨不忍睹,反映的是其生成的汇编代码优化质量糟糕,而不是C++源码层面的属性问题。对比CC与NCC在未优化时产生的汇编输出,两者其实完全一致)。

接下来的一组测试着眼于继承关系对数据成员指针性能的影响。在第一项测试中,原先独立的Point类被重新设计成一个三层单继承体系,每层类持有一个坐标值成员:

接下来的表示法仍保留三层单继承体系,但引入了一层虚继承:Point2d 虚继承自 Point。这样一来,每次访问 Point::x 时,都是在访问一个虚基类的数据成员。之后,更多是出于好奇而非实际需要,最终版本又加入了第二层虚继承,即 Point3d 虚继承自 Point2d。表 3.4 展示了测试结果(注意:NCC 优化器在各轮测试中表现始终糟糕,故未将其列入表中):

由于继承而来的数据成员直接存储在类对象内部,引入继承对代码性能完全没有影响。而引入虚继承的主要后果,是削弱了优化器的效能。原因何在?在这两种实现中,每增加一层虚继承,就会额外引入一层间接访问。对于两种实现而言,每次对 Point::x 的访问,比如:

都会被转译成(对象中的虚基类指针指向对象中虚基类对象所在位置):

而非更直接的:

正是这多出来的一层间接操作,降低了优化器将所有处理过程搬进寄存器的能力。

相关推荐
Imxyk2 小时前
P9242 [蓝桥杯 2023 省 B] 接龙数列
c++·算法·图论
_李小白2 小时前
【OSG学习笔记】Day 35: Material(材质)
笔记·学习·材质
ZhiqianXia2 小时前
Pytorch 学习笔记(21) : PyTorch Profiler
pytorch·笔记·学习
iiiiii112 小时前
【论文阅读笔记】ReVal:让大模型强化学习真正支持离策略(off-policy)数据复用
论文阅读·笔记·语言模型·大模型·llm
炽烈小老头2 小时前
【每天学习一点算法 2026/04/10】Excel表列序号
学习·算法
郝学胜-神的一滴2 小时前
二叉树后序遍历:从递归到非递归的优雅实现
数据结构·c++·程序人生·算法·
亚马逊云开发者2 小时前
GameLift Servers DDoS防护实战:Player Gateway + Ping Beacons延迟优化 + C++ SDK集成
c++·gateway·ddos
渡我白衣2 小时前
运筹帷幄——在线学习与实时预测系统
人工智能·深度学习·神经网络·学习·算法·机器学习·caffe
ZhiqianXia2 小时前
PyTorch学习笔记(16):scheduler.py
pytorch·笔记·学习