文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

流程控制语句 if 是怎么实现的?

2024-11-28 16:08

关注

楔子

前面我们分析了虚拟机执行字节码的原理,并且也介绍了不少指令,但这些指令都是从上往下顺序执行的,不涉及任何的跳转。而像流程控制语句,比如 if、for、while、try 等等,它们在执行时会发生跳转,因此 Python 底层一定还存在相应的跳转指令。

那么从现在开始,就来分析一下这些流程控制语句的实现原理,本文先来介绍 if 语句。

if 字节码

if 语句应该是最简单也是最常用的流程控制语句,那么它的字节码是怎么样的呢?当然这里的 if 语句指的是 if elif else 整体,里面的某个条件叫做该 if 语句的分支。

我们看一下 if 语句的字节码长什么样子。

import dis

code_string = """
score = 90

if score >= 85:
    print("Good")
    
elif score >= 60:
    print("Normal")

else:
    print("Bad")
"""

dis.dis(compile(code_string, "", "exec"))

反编译得到的字节码指令比较多,我们来慢慢分析。另外为了阅读方便,源代码行号就不显示了。

0 RESUME                   0
      # 加载常量 90 并压入运行时栈
      2 LOAD_CONST               0 (90)
      # 加载符号表中索引为 0 的符号 "score"
      # 弹出运行时栈的栈顶元素 90
      # 然后将两者绑定起来,存放在当前的名字空间中
      4 STORE_NAME               0 (score)
      
      # 加载变量 score
      6 LOAD_NAME                0 (score)
      # 加载常量 85
      8 LOAD_CONST               1 (85)
      # 进行比较,操作符是 >=,这个指令之前介绍过的
     10 COMPARE_OP              92 (>=)
      # 如果比较结果为 False,就进行跳转,从名字也能看出指令的含义
      # 那么跳转到什么地方呢?指令参数 9 表示向前跳转 9 个指令
      # 根据后面的提示,我们看到会跳转到偏移量为 34 的指令
      # 很明显,就是当前分支的下一个分支。关于具体是怎么跳转的,一会儿说
     14 POP_JUMP_IF_FALSE        9 (to 34)
      # 如果走到这里说明没有跳转,当前分支的条件为真
      # 那么开始执行该分支内部的逻辑
      # PUSH_NULL 指令做的事情很简单,就是往栈里 PUSH 一个 NULL
     16 PUSH_NULL
      # 以下 4 条指令对应 print("Good")
     18 LOAD_NAME                1 (print)
     20 LOAD_CONST               2 ('Good')
     22 CALL                     1
     30 POP_TOP
      # if 语句只有一个分支会被执行
      # 如果执行了某个分支,那么整个 if 语句就结束了
     32 RETURN_CONST             6 (None)
      
      # 对应 score >= 60
>>   34 LOAD_NAME                0 (score)
     36 LOAD_CONST               3 (60)
     38 COMPARE_OP              92 (>=)
      # 如果比较结果为假,跳转到偏移量为 62 的指令
     42 POP_JUMP_IF_FALSE        9 (to 62)
      # 和上面类似
     44 PUSH_NULL
     46 LOAD_NAME                1 (print)
     48 LOAD_CONST               4 ('Normal')
     50 CALL                     1
     58 POP_TOP
     60 RETURN_CONST             6 (None)
      
      # 最后一个是 else 分支,而 else 分支没有判断条件
      # 逻辑依旧和上面类似
>>   62 PUSH_NULL
     64 LOAD_NAME                1 (print)
     66 LOAD_CONST               5 ('Bad')
     68 CALL                     1
     76 POP_TOP
     78 RETURN_CONST             6 (None)

我们看到字节码偏移量之前有几个 >> 这样的符号,显然这是 if 语句中的每一个分支开始的地方。

经过分析,整个 if 语句的字节码指令还是很简单的。就是从上到下依次判断每一个分支,如果某个分支条件成立,就执行该分支的代码,执行完毕后结束整个 if 语句;否则跳转到下一个分支。

显然核心就在于 POP_JUMP_IF_FALSE 指令,我们看一下它的逻辑。

POP_JUMP_IF_FALSE

COMPARE_OP 执行完之后会将比较的结果压入运行时栈,而该指令则是将结果从栈顶弹出并判断真假。如果为假,那么跳到下一个分支,否则执行此分支的代码。

TARGET(POP_JUMP_IF_FALSE) {
    PREDICTED(POP_JUMP_IF_FALSE);
    // 从栈顶弹出比较结果,当然这里其实只是获取
    // 如果再结合最下面的 STACK_SHRINK(1),那么等价于弹出
    PyObject *cond = stack_pointer[-1];
    #line 2157 "Python/bytecodes.c"
    // 如果 cond is False,那么 Py_IsFalse(cond) 就是真
    // 此时会通过 JUMPBY 跳转到 if 语句的下一个分支,关于 JUMPBY 一会儿介绍
    if (Py_IsFalse(cond)) {
        JUMPBY(oparg);
    }
    // 但对于 if 语句来说,除了 False 之外,像 None、0、""、[] 等也表示假
    // 那么当 cond 也不是 True 的时候(说明不是布尔值),要继续判断
    else if (!Py_IsTrue(cond)) {
        // Py_IsTrue(cond):等价于 Python 的 cond is True
        // PyObject_IsTrue(cond):等价于 Python 的 bool(cond) is True
        // 所以 if cond 和 if bool(cond) 是等价的
        int err = PyObject_IsTrue(cond);
        #line 3047 "Python/generated_cases.c.h"
        Py_DECREF(cond);
        #line 2163 "Python/bytecodes.c"
        // 如果 PyObject_IsTrue 返回 0,说明 bool(cond) 不是 True
        // 即 cond 为假,意味着条件不成立,那么要跳转到 if 语句的下一个分支
        if (err == 0) {
            JUMPBY(oparg);
        }
        // 如果返回值小于 0,说明出错了(基本不会发生)
        // 由于运行时栈里面还有一个元素
        // 那么跳转到帧评估函数中的 pop_1_error
        else {
            if (err < 0) goto pop_1_error;
        }
    }
    #line 3057 "Python/generated_cases.c.h"
    STACK_SHRINK(1);
    DISPATCH();
}

逻辑不难理解,但是里面出现了几个判断布尔值的函数,我们补充一下。

// Objects/object.c

// 等价于 Python 的 x is y
int Py_Is(PyObject *x, PyObject *y)
{   
    return (x == y);
}

// 等价于 Python 的 x is None
int Py_IsNone(PyObject *x)
{
    return Py_Is(x, Py_None);
}

// 等价于 Python 的 x is True
int Py_IsTrue(PyObject *x)
{
    return Py_Is(x, Py_True);
}

// 等价于 Python 的 x is False
int Py_IsFalse(PyObject *x)
{
    return Py_Is(x, Py_False);
}

// 等价于 Python 的 bool(v) is True
int
PyObject_IsTrue(PyObject *v)
{
    Py_ssize_t res;
    // 如果 v 本身就是布尔值 True,返回 1
    if (v == Py_True)
        return 1;
    // 如果 v 本身就是布尔值 False,返回 0
    if (v == Py_False)
        return 0;
    // 如果 v 是 None,返回 0
    if (v == Py_None)
        return 0;
    // 如果 v 是数值型对象,并且实现了 nb_bool(对应 __bool__)
    // 那么调用,如果结果不为 0,返回 1,否则返回 0
    else if (Py_TYPE(v)->tp_as_number != NULL &&
             Py_TYPE(v)->tp_as_number->nb_bool != NULL)
        res = (*Py_TYPE(v)->tp_as_number->nb_bool)(v);
    // 如果 v 是映射型对象,并且实现了 mp_length(对应 __len__)
    // 那么调用,返回对象的长度
    else if (Py_TYPE(v)->tp_as_mapping != NULL &&
             Py_TYPE(v)->tp_as_mapping->mp_length != NULL)
        res = (*Py_TYPE(v)->tp_as_mapping->mp_length)(v);
    // 如果 v 是序列型对象,并且实现了 sq_length(对应 __len__)
    // 那么调用,返回对象的长度
    else if (Py_TYPE(v)->tp_as_sequence != NULL &&
             Py_TYPE(v)->tp_as_sequence->sq_length != NULL)
        res = (*Py_TYPE(v)->tp_as_sequence->sq_length)(v);
    // 如果以上条件都不满足,直接返回 1,比如自定义类的实例对象(默认为真)
    else
        return 1;
    // 如果 res > 0 返回 1,否则返回 0
    return (res > 0) ? 1 : Py_SAFE_DOWNCAST(res, Py_ssize_t, int);
}

// 等价于 Python 的 not v
int
PyObject_Not(PyObject *v)
{
    int res;
    res = PyObject_IsTrue(v);
    if (res < 0)
        return res;
    // 如果 v 是真,res == 1,那么 res == 0 结果是 0
    // 如果 v 是假,res == 0,那么 res == 0 结果是 1
    // 相当于取反
    return res == 0;
}

// Objects/boolobject.c
static PyObject *
bool_new(PyTypeObject *type, PyObject *args, PyObject *kwds)
{
    //  是一个 Python 类,这里的 bool_new 便是它的构造函数
    PyObject *x = Py_False;
    long ok;
    // 不接收关键字参数
    if (!_PyArg_NoKeywords("bool", kwds))
        return NULL;
    // 只接收 0 ~ 1 个参数,如果不传,那么默认返回 False
    if (!PyArg_UnpackTuple(args, "bool", 0, 1, &x))
        return NULL;
    // 调用 PyObject_IsTrue,所以我们说 if v 和 if bool(v) 是等价的
    // 因为当 v 不是布尔值时,if v 对应的指令内部会调用 PyObject_IsTrue
    // 而 bool(v) 也会调用 PyObject_IsTrue,所以两者是等价的
    ok = PyObject_IsTrue(x);
    if (ok < 0)
        return NULL;
    // 调用 PyBool_FromLong 创建布尔值,ok 为 1 返回 True,为 0 返回 False
    return PyBool_FromLong(ok);
}

PyObject *PyBool_FromLong(long ok)
{
    return ok ? Py_True : Py_False;
}

相信你现在明白了为什么 if 后面不跟布尔值也是可以的,因为有一个 C 函数 PyObject_IsTrue,可以判断任意对象的真假。如果 if 后面跟着的不是布尔值,那么会自动调用该函数。另外由于 bool(v) 也会调用该函数,所以 if v 和 if bool(v) 是等价的。

注:没有 PyObject_IsFalse。

说完了 POP_JUMP_IF_FALSE 指令,再补充一个和它相似的指令叫 POP_JUMP_IF_TRUE,它表示当比较结果为真时,跳到下一个分支,否则执行当前分支的代码。可能有人觉得,这不对吧,比较结果为真,难道不应该执行当前分支的逻辑吗?所以 POP_JUMP_IF_TRUE 指令似乎本身就是矛盾的。

仔细想想你应该能够猜到原因,答案就是使用了 not。

import dis

code_string = """
if 2 > 1:
    print("古明地觉")
"""
# 只打印部分字节码
dis.dis(compile(code_string, "", "exec"))
"""
     2 LOAD_CONST               0 (2)
     4 LOAD_CONST               1 (1)
     6 COMPARE_OP              68 (>)
    10 POP_JUMP_IF_FALSE        9 (to 30)
"""             

code_string = """
if not 2 > 1:
    print("古明地觉")
"""
dis.dis(compile(code_string, "", "exec"))
"""
     2 LOAD_CONST               0 (2)
     4 LOAD_CONST               1 (1)
     6 COMPARE_OP              68 (>)
    10 POP_JUMP_IF_TRUE         9 (to 30)
"""

正常情况下如果比较结果为 False,则跳转到 if 语句的下一个分支,所以 POP_JUMP_IF_FALSE 指令是合理的。至于 POP_JUMP_IF_TRUE 指令从逻辑上似乎就不该存在,因为它和 if 语句本身是相矛盾的。

但现在我们明白了,该指令其实是为 not 关键字准备的。如果比较结果为真,那么 not 取反就是假,于是跳转到 if 语句的下一个分支,所以整个逻辑依旧是正确的。

当然这里只有一个 not,即使有很多个 not 也是可以的,尽管这没太大意义。

import dis

# 这里有 4 个 not,因为是偶数个,两两相互抵消
# 所以结果等价于 if 2 > 1
code_string = """
if not not not not 2 > 1:
    print("古明地觉")
"""
# 只打印部分字节码
dis.dis(compile(code_string, "", "exec"))
"""
     2 LOAD_CONST               0 (2)
     4 LOAD_CONST               1 (1)
     6 COMPARE_OP              68 (>)
    10 POP_JUMP_IF_FALSE        9 (to 30)
"""             

# 这里有 5 个 not,因为是奇数个,两两相互抵消之后还剩下一个
# 所以结果等价于 if not 2 > 1
code_string = """
if not not not not not 2 > 1:
    print("古明地觉")
"""
dis.dis(compile(code_string, "", "exec"))
"""
     2 LOAD_CONST               0 (2)
     4 LOAD_CONST               1 (1)
     6 COMPARE_OP              68 (>)
    10 POP_JUMP_IF_TRUE         9 (to 30)
"""

然后再看一下 POP_JUMP_IF_TRUE 指令的内部逻辑,显然它和 POP_JUMP_IF_FALSE 是类似的。

TARGET(POP_JUMP_IF_TRUE) {
    // 获取栈顶元素
    PyObject *cond = stack_pointer[-1];
    #line 2173 "Python/bytecodes.c"
    // 如果 cond is True,跳转到 if 语句的下一个分支
    if (Py_IsTrue(cond)) {
        JUMPBY(oparg);
    }
    // 如果 cond 不是 True,那么看 bool(cond) 是否为 True
    else if (!Py_IsFalse(cond)) {
        int err = PyObject_IsTrue(cond);
    #line 3070 "Python/generated_cases.c.h"
        Py_DECREF(cond);
    #line 2179 "Python/bytecodes.c"
        // err > 0,说明结果为真,跳转到 if 语句的下一个分支
        if (err > 0) {
            JUMPBY(oparg);
        }
        // 否则说明比较出错了(基本不会发生)
        else {
            if (err < 0) goto pop_1_error;
        }
    }
    #line 3080 "Python/generated_cases.c.h"
    STACK_SHRINK(1);
    DISPATCH();
}

以上就是 POP_JUMP_IF_FALSE 和 POP_JUMP_IF_TRUE 的内部逻辑,可以说非常简单。

JUMPBY

指令跳转是由 JUMPBY 实现的,它内部的逻辑长啥样呢?

// Python/ceval_macros.h
#define JUMPBY(x)       (next_instr += (x))

字节码指令的遍历是通过 next_instr 实现的,如果将指令执行的方向代表前进的方向,显然它表示从当前指令所在的位置向前跳转 x 个指令。

图片图片

POP_JUMP_IF_FALSE 指令的偏移量为 14,向前跳转 9 个指令,一个指令的大小是 2 字节,所以结果是 14 + 18 = 32。咦,不是应该跳转到偏移量为 34 的指令吗,为啥结果是 32 呢?

很简单,TARGET 是一个宏,它在展开之后,还会对 next_instr 做一次自增操作。

图片图片

除了 JUMPBY 之外,还有一个 JUMPTO,它表示从字节码的起始位置向前跳转 x 个指令。

// Python/ceval_macros.h

// 从字节码的起始位置向前跳转 x 个指令
#define JUMPTO(x)       (next_instr = _PyCode_CODE(frame->f_code) + (x))
// 从 next_instr 处(指向当前待执行的指令)向前跳转 x 个指令
#define JUMPBY(x)       (next_instr += (x))

所以 JUMPTO 表示绝对跳转,JUMPBY 表示相对跳转。不难发现,JUMPTO 既可以向前跳转(偏移量增大),也可以向后跳转(偏移量减小);而 JUMPBY 只能向前跳转。

假设参数为 n,当前指令的偏移量为 m。对于 JUMPTO 而言,跳转之后的偏移量始终为 2n,如果 m < 2n 就是向前跳转,m > 2n 就是向后跳转。但对于 JUMP_BY 而言,由于它是从当前待执行的指令开始跳转的,所以只能向前跳转(偏移量增大)。

指令预测

CPython 3.12 里面引入了计算跳转,可以避免不必要的匹配。因为整个指令集合是已知的,这就说明某条指令在执行时,便可知道它的下一条指令是什么。

所以当前指令处理完后,可以直接跳转到下一条指令对应的处理逻辑中,这就是计算跳转。但如果不使用计算跳转,那么每次读取到指令后,都要进入 switch,顺序匹配数百个 case 分支,找到匹配成功的那一个。

因此使用计算跳转可以避免不必要的匹配,既然提前知道下一条指令是啥了,那么直接精确跳转就行,无需多走一遍 switch。不过要想实现计算跳转,需要 GCC 支持标签作为值,即 goto *label_addr 用法,由于 label_addr 是一个标签地址,那么解引用之后就是标签了。至于具体会跳转到哪一个标签,取决于 label_addr 保存了哪一个标签的地址,因此这种跳转是动态的,在运行时决定跳转目标。

goto 标签:静态跳转,标签需要显式地定义好,跳转位置在编译期间便已经固定。

goto *标签地址:动态跳转(计算跳转),跳转位置不固定,可以是已有标签中的任意一个。至于具体是哪一个,需要在运行时经过计算才能确定。

虚拟机为每个指令的处理逻辑都定义了一个标签,对于计算跳转来说,goto 的结果是 *标签地址,这个地址是运行时计算得出的。我们举个例子,随便看一段字节码指令集。

比如当前正在执行 LOAD_NAME 指令,那么下一条指令可以是 STORE_NAME、LOAD_NAME 以及 BUILD_LIST 等。当开启计算跳转时:

所以在运行时判断指令的值,获取对应的标签,从而实现精确跳转,这就是计算跳转。当然这些内容在剖析虚拟机执行字节码时已经说过了,这里再回顾一下。

接下来说一说指令预测,不难发现,如果是计算跳转,那么指令预测功能貌似没啥用,因为总是能精确跳转到下一个指令对应的标签中。没错,指令预测只有在不使用计算跳转的情况下有用,那什么是指令预测呢?

在不使用计算跳转时,goto 后面必须是一个静态的标签,跳转位置在编译阶段便已经固定,换句话说一个指令执行完毕后要跳转到哪一个标签是写死的,不能保证跳转后的标签正好对应下一条指令的处理逻辑。比如 LOAD_NAME 的下一条指令可以是 STORE_NAME 和 BUILD_LIST,那么应该跳转到哪一个指令对应的标签中呢?

正因为这种不确定性,绝大部分指令在执行完毕后都会直接跳转到 switch 语句所在位置,然后顺序匹配 case 分支。

但也有那么几个指令,由于彼此的关联性很强,很多时候都是成对出现的,面对这样的指令,虚拟机会进行预测。比如 A 和 B 两个指令的关联性很强,尽管 A 的下一条指令除了是 B 之外,也有可能是其它指令,但 B 出现的概率是最大的,因此虚拟机会预测下一条指令是 B 指令。于是在执行完 A 指令之后,会验证自己的预测是否正确,即检测下一条指令是否是 B 指令。如果预测对了,可以实现精确跳转,如果预测错了,就只能回到 switch 语句逐一匹配 case 分支了。

总结一下:指令在执行时,它的下一条指令是已知的,但是不固定,有多种可能。如果不使用计算跳转,由于 goto 后面必须是一个写死的标签,而下一条指令却不固定,那么只能跳转到 switch 语句所在的位置,顺序匹配 case 分支。但也有那么几对指令,关联性很强,虽然不能保证百分百,但值得做一次尝试,这便是指令预测。

当然啦,如果使用计算跳转,情况则不一样了,此时压根用不到指令预测。因为 goto 后面是 *标签地址,而地址是可以动态获取的。由于所有标签的地址都保存在了一个数组中,不管接下来要处理哪一条指令,都可以获取到对应的标签地址,实现精确跳转。

以上有很多都是之前说过的内容,再重复一遍加深印象。好,关于指令预测我们已经知道是啥了,那么在源码层面又是如何体现的呢?

在 POP_JUMP_IF_FALSE 指令中,我们看到有这么一行逻辑。

图片图片

里面有一个宏 PREDICTED。

// Python/ceval_macros.h

#define PREDICTED(op)           PREDICT_ID(op):
#define PREDICT_ID(op)          PRED_##op

这个宏展开之后又是一个标签,由于调用时结尾加了分号,所以这还是一个空标签。整体效果如下:

图片图片

那么它有什么用呢?我们再看一个指令就明白了。

图片图片

MATCH_SEQUENCE 指令是做什么的,我们以后再说,总之虚拟机认为该指令执行完之后,大概率会执行 POP_JUMP_IF_FALSE 指令,所以做了一个预测。而相关逻辑位于 PREDICT 中,看一下它长什么样子。

// Python/ceval_macros.h

#define PREDICT_ID(op)          PRED_##op

// 如果开启计算跳转,那么指令预测不生效
// 因为本身就知道该跳转到哪个指令对应的标签
#if USE_COMPUTED_GOTOS
#define PREDICT(op)             if (0) goto PREDICT_ID(op)
#else
// 如果不开启计算跳转,那么会比较预测的指令和实际的指令是否相等
// 所以 MATCH_SEQUENCE 指令处理逻辑里面的 PREDICT(POP_JUMP_IF_FALSE)
// 就是在判断下一条指令是不是自己预测的 POP_JUMP_IF_FALSE
// 如果是,说明预测成功,那么 goto PRED_POP_JUMP_IF_FALSE
// 否则说明预测失败,那么会执行 DISPATCH(),然后 goto 到 switch 语句所在位置
#define PREDICT(next_op) \
    do { \
        _Py_CODEUNIT word = *next_instr; \
        opcode = word.op.code; \
        if (opcode == next_op) { \
            oparg = word.op.arg; \
            INSTRUCTION_START(next_op); \
            goto PREDICT_ID(next_op); \
        } \
    } while(0)
#endif

以上便是指令预测,说白了就是如果指令 A 和指令 B 具有极高的关联性(甚至百分百),那么执行完 A 指令后会判断下一条指令是不是 B。如果是,那么直接跳转即可,就省去了匹配 case 分支的时间,如果不是,那就只能挨个匹配了。

因为是静态跳转,goto 后面的标签是写死的,编译阶段就确定了,所以只有那种关联度极高的指令才会开启预测功能,因为预测成功的概率比较高。但如果指令 A 的下一条指令有多种可能(假设有 6 种),并且每种指令出现的概率还差不多,那么这时不管预测哪一个,成功的概率都只有 1/6。显然这就不叫预测了,这是在掷骰子,因此对于这样的指令,虚拟机不会为它开启预测功能。

图片图片

比如 LOAD_NAME 的下一个指令可以是 STORE_NAME、LOAD_NAME、BUILD_LIST 等等,不管预测哪一种,成功的概率都不是特别高,因此它没有进行指令预测。

所以就一句话:只有 A 和 B 两个指令的关联度极高的时候,执行 A 之后才会预测下一条指令是否是 B。预测成功直接跳转,预测失败执行 DISPATCH(),跳转到 switch 语句所在位置,即 dispatch_opcode 标签。

但如果使用了计算跳转,情况就不一样了,此时不会开启指令预测,或者说指令预测里的逻辑会变得无效。

图片图片

很明显,使用计算跳转后,PREDICT(op) 不会产生任何效果,因此也可以理解为没有开启指令预测。而之所以不用预测,是因为执行 DISPATCH() 的时候,本身就可以精确跳转到指定位置。

小结

本篇文章我们就分析了 if 语句的实现原理,总的来说不难理解。依旧是在栈桢中执行字节码,只是多了一个指令跳转罢了,至于怎么跳转、跳转到什么地方,全部都体现在字节码中。

来源:古明地觉的编程教室内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     813人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     354人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     318人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     435人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-后端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯