文章详情

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

请输入下面的图形验证码

提交验证

短信预约提醒成功

闭包是怎么实现的?你知道吗?

2024-11-28 13:57

关注

楔子

在之前的文章中一直反复提到四个字:名字空间。一段代码执行的结果不光取决于代码中的符号,更多的是取决于代码中符号的语义,而这个运行时的语义正是由名字空间决定的。

名字空间是由虚拟机在运行时动态维护的,但有时我们希望将名字空间静态化。换句话说,我们希望有的代码不受名字空间变化带来的影响,始终保持一致的功能该怎么办呢?随便举个例子:

def login(user_name, password, user):
    if not (user_name == "satori" and password == "123"):
        return "用户名密码不正确"
    else:
        return f"欢迎: {user}"

print(login("satori", "123", "古明地觉"))  # 欢迎: 古明地觉
print(login("satori", "123", "古明地恋"))  # 欢迎: 古明地恋

我们注意到每次都需要输入 username 和 password,于是可以通过使用嵌套函数来设置一个基准值。

def deco(user_name, password):
    def login(user):
        if not (user_name == "satori" and password == "123"):
            return "用户名密码不正确"
        else:
            return f"欢迎: {user}"
    return login

login = deco("satori", "123")
print(login("古明地觉"))  # 欢迎: 古明地觉
print(login("古明地恋"))  # 欢迎: 古明地恋

尽管函数 login 里面没有 user_name 和 password 这两个局部变量,但是不妨碍我们使用它,因为外层函数 deco 里面有。

也就是说,函数 login 作为函数 deco 的返回值被返回的时候,有一个名字空间就已经和 login 紧紧地绑定在一起了。执行内层函数 login 的时候,对于自身 local 空间中不存在的变量,会从和自己绑定的 local 空间里面去找,这就是一种将名字空间静态化的方法。这个名字空间和内层函数捆绑之后的结果我们称之为闭包(closure)。

为了描述方便,上面说的是 local 空间,但我们知道,局部变量不是从那里查找的,而是从 localsplus 里面。只是我们可以按照 LEGB 的规则去理解,这一点心理清楚就行。

也就是说:闭包=外部作用域+内层函数。并且在介绍函数的时候提到,PyFunctionObject 是虚拟机专门为字节码指令的传输而准备的大包袱,global 名字空间、默认参数都和字节码指令捆绑在一起,同样的,也包括闭包。

实现闭包的基石

闭包的创建通常是利用嵌套函数来完成的,我们说过局部变量是通过数组静态存储的,而闭包也是如此。这里再来回顾一下 PyCodeObject 里面的几个关键字段:

因此不难得出它们之间的关系:

那么这些变量的值都存在什么地方呢?没错就是栈帧的 localsplus 字段中。

图片图片

我们看一段代码:

def foo():
    name = "古明地觉"
    age = 17
    gender = "female"

    def bar():
        nonlocal name
        nonlocal age
        print(gender)

    return bar

print(foo.__code__.co_cellvars)  # ('age', 'gender', 'name')
print(foo().__code__.co_freevars)  # ('age', 'gender', 'name')
print(foo.__code__.co_freevars)  # ()
print(foo().__code__.co_cellvars)  # ()

和闭包相关的两个字段是 co_cellvars 和 co_freevars。co_cellvars 保存了外层作用域中被内层作用域引用的变量的名字,co_freevars 保存了内层作用域中引用的外层作用域的变量的名字。

所以对于外层函数来说,应该使用 co_cellvars,对于内层函数来说,应该使用 co_freevars。当然无论是外层函数还是内层函数都有 co_cellvars 和 co_freevars,这是肯定的,因为都是函数。

只不过外层函数需要使用 co_cellvars 获取,因为它包含的是外层函数中被内层函数引用的变量的名称;内层函数需要使用 co_freevars 获取,它包含的是内层函数中引用的外层函数的变量的名称。

如果使用外层函数 foo 获取 co_freevars 的话,那么得到的结果显然就是个空元组了,除非 foo 也作为某个函数的内层函数,并且内部引用了外层函数的变量。同理内层函数 bar 也是一样的道理,它获取 co_cellvars 得到的也是空元组,因为对于 bar 而言不存在内层函数。

我们再看个例子:

def foo():
    name = "古明地觉"
    age = 17

    def bar():
        nonlocal name
        nonlocal age
        gender = "female"

        def inner():
            nonlocal gender

        return inner

    return bar

print(foo().__code__.co_cellvars)  # ('gender',)
print(foo().__code__.co_freevars)  # ('age', 'name')

对于函数 bar 而言,它是函数 inner 的外层函数,同时也是函数 foo 的内层函数。所以它在获取 co_cellvars 和 co_freevars 属性时,得到的元组都不为空。因为内层函数 inner 引用了函数 bar 里面的变量 gender,同时函数 bar 也作为内层函数引用了函数 foo 里的 name 和 age。

那么问题来了,闭包变量所需要的空间申请在哪个地方呢?没错,显然是 localsplus。

在以前的版本中,这个字段叫 f_localsplus,现在叫 localsplus。

localplus 是一个柔性数组,它被分成了四份,分别用于:局部变量、cell 变量、free 变量、运行时栈。

所以闭包变量同样是以静态的方式实现的。

闭包的实现过程

介绍完实现闭包的基石之后,我们可以开始追踪闭包的具体实现过程了,当然还是要先看一下闭包对应的字节码。

import dis

code_string = """
def some_func():
    name = "satori"
    age = 17
    gender = "female"
    def inner():
        print(name, age)

    return inner

func = some_func()
func()
"""

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

字节码指令如下,为了阅读方便,我们省略了源代码行号。

# ********** 模块对应的字节码 **********
  0 RESUME                   0
  # 加载函数 some_func 对应的 PyCodeObject,压入运行时栈
  2 LOAD_CONST               0 ()
  # 从栈顶弹出 PyCodeObject,构造 PyFunctionObject,并压入运行时栈
  4 MAKE_FUNCTION            0
  # 从栈顶弹出 PyFunctionObject,然后使用变量 some_func 保存
  6 STORE_NAME               0 (some_func)

  8 PUSH_NULL
  # 加载全局变量 some_func
 10 LOAD_NAME                0 (some_func)
  # 调用
 12 CALL                     0
  # 弹出栈顶的返回值,并使用变量 func 保存
 20 STORE_NAME               1 (func)

 22 PUSH_NULL
  # 加载全局变量 func
 24 LOAD_NAME                1 (func)
  # 调用
 26 CALL                     0
  # 从栈顶弹出返回值,丢弃
 34 POP_TOP
  # 隐式地 return None
 36 RETURN_CONST             1 (None)

  # ********** 外层函数 some_func 对应的字节码 **********
Disassembly of :
  # 创建 cell 对象 PyCellObject,该指令一会儿说
  0 MAKE_CELL                2 (age)
  2 MAKE_CELL                3 (name)

  4 RESUME                   0
  # 加载常量 "satori"
  6 LOAD_CONST               1 ('satori')
  # 注意这里不是 STORE_FAST,而是 STORE_DEREF
  # 它的作用肯定是将符号 "name" 和字符串常量绑定起来
  # STORE_NAME、STORE_FAST、STORE_DEREF 做的事情是一样的
  # 都是将符号和值绑定起来,只是绑定的方式不一样
  # 比如 STORE_NAME 是通过字典完成绑定,STORE_FAST 是通过数组完成绑定
  # 那么 STORE_DEREF 是怎么绑定的呢?稍后分析  
  8 STORE_DEREF              3 (name)
  # 加载常量 17
 10 LOAD_CONST               2 (17)
  # 使用变量 age 保存
 12 STORE_DEREF              2 (age)
  # name 和 age 被内层函数引用了,所以是 STORE_DEREF
  # 但 gender 没有,所以它对应的是 STORE_FAST
 14 LOAD_CONST               3 ('female')
 16 STORE_FAST               0 (gender)
  # 加载 cell 变量,压入运行时栈
 18 LOAD_CLOSURE             2 (age)
 20 LOAD_CLOSURE             3 (name)
  # 弹出 cell 变量,构建元组
 22 BUILD_TUPLE              2
  # 加载函数 inner 对应的 PyCodeObject
 24 LOAD_CONST               4 ()
  # 构造函数
 26 MAKE_FUNCTION            8 (closure)
  # 将函数使用 inner 变量保存
 28 STORE_FAST               1 (inner)
  # return inner
 30 LOAD_FAST                1 (inner)
 32 RETURN_VALUE
 
 # ********** 内层函数 inner 对应的字节码 **********
Disassembly of :
  0 COPY_FREE_VARS           2

  2 RESUME                   0
  # 加载内置变量 print
  4 LOAD_GLOBAL              1 (NULL + print)
  # 显然它和 LOAD_NAME、LOAD_FAST 的关系也是类似的
  # 也是负责加载变量,然后压入运行时栈  
 14 LOAD_DEREF               1 (name)
 16 LOAD_DEREF               0 (age)
  # 调用 print 函数
 18 CALL                     2
  # 从栈顶弹出返回值,丢弃
 26 POP_TOP
 28 RETURN_CONST             0 (None)

字节码的内容并不难,我们来分析一下,这里先分析外层函数 some_func 对应的字节码。

图片图片

函数 some_func 里面有三个局部变量,但只有 name 和 age 被内层函数引用了,所以开头有两个 MAKE_CELL 指令。参数为符号在符号表中的索引,对应的符号分别为 age 和 name。我们来看一下这个指令是做什么的。

TARGET(MAKE_CELL) {
    #line 1394 "Python/bytecodes.c"
    // 符号在符号表中的索引,和对应的值在 localplus 中的索引是一致的
    // 所以这里会获取变量对应的值,对于当前来说就是 age 和 name 的值
    // 但很明显此时 name 和 age 还没有完成赋值,所以结果为 NULL
    PyObject *initial = GETLOCAL(oparg);
    // 调用 PyCell_New 创建 Cell 对象
    PyObject *cell = PyCell_New(initial);
    if (cell == NULL) {
        goto resume_with_error;
    }
    // 用 Cell 对象替换掉原来的值
    SETLOCAL(oparg, cell);
    #line 1909 "Python/generated_cases.c.h"
    DISPATCH();
}

// Objects/cellobject.c
PyObject *
PyCell_New(PyObject *obj)
{
    PyCellObject *op;
    // 为 PyCellObject 申请内存
    op = (PyCellObject *)PyObject_GC_New(PyCellObject, &PyCell_Type);
    if (op == NULL)
        return NULL;
    // 增加 obj 指向对象的引用计数,并赋值给 op->ob_ref
    op->ob_ref = Py_XNewRef(obj);

    _PyObject_GC_TRACK(op);
    return (PyObject *)op;
}

// Include/cpython/cellobject.h
typedef struct {
    PyObject_HEAD
    PyObject *ob_ref;
} PyCellObject;

所以 MAKE_CELL 指令的作用是创建 PyCellObject,对于当前来说,会创建两个 PyCellObejct,它们的 ob_ref 字段分别为 age 和 name。只不过由于 name 和 age 还尚未完成赋值,所以此时为 NULL。

图片图片

接下来就是变量赋值,这个显然没什么难度,我们只需要看一下 STORE_DEREF 指令。并且也容易得出结论,如果局部变量被内层函数所引用,那么指令将不再是 LOAD_FAST 和 STORE_FAST,而是 LOAD_DEREF 和 STORE_DEREF。

TARGET(STORE_DEREF) {
    // 由于在 STORE_DEREF 之前调用了 LOAD_CONST
    // 所以这里会获取上一步压入的常量,对于当前来说就是 17 和 "satori"
    PyObject *v = stack_pointer[-1];
    #line 1463 "Python/bytecodes.c"
    // 这里的 oparg 和 MAKE_CELL 的 oparg 的含义是一样的
    // 此时会拿到对应的 PyCellObject *
    PyObject *cell = GETLOCAL(oparg);
    // 获取 PyCellObject 内部的 ob_ref,并减少引用计数
    PyObject *oldobj = PyCell_GET(cell);
    // 将 PyCellObject 内部的 ob_ref 设置成 v
    PyCell_SET(cell, v);
    Py_XDECREF(oldobj);
    #line 1993 "Python/generated_cases.c.h"
    // 栈收缩
    STACK_SHRINK(1);
    DISPATCH();
}

localplus 保存了局部变量的值,而符号在符号表中的索引,和对应的值在 localplus 中的索引是一致的。所以正常情况下,局部变量赋值就是 localsplus[oparg] = v。

但在执行 MAKE_CELL 指令之后,局部变量赋值就变成了 localsplus[oparg]->ob_ref = v,因为此时 localplus 保存的是 PyCellObject 的地址。

因此在两个 STORE_DEREF 执行完之后,localplus 会变成下面这样。

相信你明白 STORE_FAST 和 STORE_DEREF 之间的区别了,如果是 STORE_FAST,那么中间就没有 PyCellObject 这一层,localsplus 保存的 PyObject * 指向的就是具体的对象。

然后是 gender = "female",它就很简单了,由于符号 "gender" 在符号表中的索引为 0,那么直接让 localplus[0] 指向字符串 "female" 即可。

到此变量 name、age、gender 均已赋值完毕,此时 localsplus 结构如下。

图片图片

localsplus[0]、localsplus[2]、localsplus[3] 分别对应变量 gender、age、name,可能有人觉得,这个索引好奇怪啊,我们实际测试一下。

def some_func():
    name = "satori"
    age = 17
    gender = "female"
    def inner():
        print(name, age)

    return inner

print(
    some_func.__code__.co_varnames
)  # ('gender', 'inner')

我们看到 some_func 的符号表里面只有 gender 和 inner,因此 localplus[0] 表示变量 gender。至于 localplus[1] 则表示变量 inner,只不过此时它指向的对象还没有创建,所以暂时为 NULL。

那么问题来了,变量 name 和 age 呢?毫无疑问,由于它们被内层函数引用了,所以它们变成了 cell 变量,并且位置是 co->co_nlocals + i。因为在 localsplus 中,cell 变量的位置是在局部变量之后的,这也完全符合我们之前说的 localsplus 的内存布局。

图片图片

并且我们看到无论是局部变量还是 cell 变量,都是通过数组索引访问的,并且索引在编译时就确定了,以指令参数的形式保存在字节码指令集中。

接下来执行偏移量为 18 和 20 的两条指令,它们都是 LOAD_CLOSURE。

// 加载 PyCellObject *,即 cell 变量,然后压入运行时栈
TARGET(LOAD_CLOSURE) {
    PyObject *value;
    #line 179 "Python/bytecodes.c"
    value = GETLOCAL(oparg);
    if (value == NULL) goto unbound_local_error;
    Py_INCREF(value);
    #line 66 "Python/generated_cases.c.h"
    STACK_GROW(1);
    stack_pointer[-1] = value;
    DISPATCH();
}

LOAD_CLOSURE 执行完毕后,接着执行 BUILD_TUPLE,将 cell 变量从栈中弹出,构建元组。然后继续执行 24 LOAD_CONST,将内层函数 inner 对应的 PyCodeObject 压入运行时栈。

接着执行 26 MAKE_FUNCTION,将栈中元素弹出,分别是 inner 对应的 PyCodeObject 和一个元组,元组里面包含了 inner 使用的外层函数的变量。当然这里的变量已经不再是普通的变量了,而是 cell 变量,它内部的 ob_ref 字段才是我们需要的。

等元素弹出之后,开始构建函数,我们看一下 MAKE_FUNCTION 指令,它的指令参数为 8。

TARGET(MAKE_FUNCTION) {
    PyObject *codeobj = stack_pointer[-1];
    PyObject *closure = (oparg & 0x08) ? stack_pointer[...] : NULL;
    // ...
    // 创建函数对象
    PyFunctionObject *func_obj = (PyFunctionObject *)
        PyFunction_New(codeobj, GLOBALS());

    Py_DECREF(codeobj);
    if (func_obj == NULL) {
        goto error;
    }
    // 由于指令参数为 8,所以 oparg & 0x08 为真
    if (oparg & 0x08) {
        assert(PyTuple_CheckExact(closure));
        func_obj->func_closure = closure;
    }
    if (oparg & 0x04) {
        assert(PyTuple_CheckExact(annotations));
        func_obj->func_annotations = annotations;
    }
    if (oparg & 0x02) {
        assert(PyDict_CheckExact(kwdefaults));
        func_obj->func_kwdefaults = kwdefaults;
    }
    if (oparg & 0x01) {
        assert(PyTuple_CheckExact(defaults));
        func_obj->func_defaults = defaults;
    }

    // ...
    DISPATCH();
}

所以 PyFunctionObject 再一次承担了工具人的角色,创建内层函数 inner 时,会将包含 cell 变量的元组赋值给 func_closure 字段。此时便将内层函数需要使用的变量和内层函数绑定在了一起,而这个绑定的结果我们就称之为闭包。

但是从结构上来看,闭包仍是一个函数,所谓绑定,其实只是修改了它的 func_closure 字段。当函数创建完毕后,localplus 的结构变化如下。

图片图片

函数即变量,对于函数 some_func 而言,内层函数 inner 也是一个局部变量,由于符号 inner 位于符号表中索引为 1 的位置。因此当函数创建完毕时,会修改 localplus[1],让它保存函数的地址。不难发现,对于局部变量来说,如何访问内存在编译阶段就确定了。

函数内部的 func_closure 字段指向一个元组,元组里面的每个元素会指向 PyCellObject。

调用闭包

闭包的创建过程我们已经了解了,我们用 Python 代码再解释一下。

def some_func():
    name = "satori"
    age = 17
    gender = "female"
    def inner():
        print(name, age)

    return inner

func = some_func()
# some_func 调用之后会返回内层函数 inner
# 只不过 inner 的 func_closure 字段保存了 cell 变量
# 而 cell 变量指向的 PyCellObject 对外层作用域的局部变量进行了冻结
# 所以我们也会称呼 inner 函数为闭包,但要知道闭包仍然是个函数
print(func.__name__)  # inner
print(func.__class__)  # 

print(
    func.__closure__[0]
)  # 
print(
    func.__closure__[1]
)  # 

print(func.__closure__[0].cell_contents)  # 17
print(func.__closure__[1].cell_contents)  # satori

调用 inner 函数时,外层函数 some_func 已经执行结束,但它的局部变量 name 和 age 仍可被内层函数 inner 访问,背后的原因我们算是彻底明白了。

因为 name 和 age 被内层函数引用了,所以虚拟机将它们封装成了 PyCellObject *,即 cell 变量,而 cell 变量指向的 cell 对象内部的 ob_ref 字段对应原来的变量。当创建内层函数时,将引用的 cell 变量组成元组,保存在内层函数的 func_closure 字段中。

所以当内层函数在访问 name 和 age 时,访问的其实是 PyCellObject 的 ob_ref 字段。至于变量 name 和 age 对应哪一个 PyCellObject,这些在编译阶段便确定了,我们看一下内层函数 inner 的字节码指令。

图片图片

函数在执行时会创建栈帧,我们上面看到的 localsplus 是外层函数 some_func 对应的栈帧的 localsplus。而内层函数 inner 执行时,也会创建栈帧,然后在栈帧中执行字节码指令。

首先第一个指令是 COPY_FREE_VARS,看一下它的逻辑。

// 将 func_closure 里面的 cell 变量拷贝到 free 区域
TARGET(COPY_FREE_VARS) {
    #line 1470 "Python/bytecodes.c"
    
    PyCodeObject *co = frame->f_code;
    assert(PyFunction_Check(frame->f_funcobj));
    // 获取 func_closure,它指向一个元组,里面保存了 PyCellObject *
    PyObject *closure = ((PyFunctionObject *)frame->f_funcobj)->func_closure;
    assert(oparg == co->co_nfreevars);
    // co_nlocalsplus 等于局部变量、cell 变量、free 变量的个数之和
    // 显然 offset 表示 free 变量对应的内存区域
    int offset = co->co_nlocalsplus - oparg;
    // 将 func_closure 里面的 PyCellObject * 拷贝到 free 区域
    for (int i = 0; i < oparg; ++i) {
        PyObject *o = PyTuple_GET_ITEM(closure, i);
        frame->localsplus[offset + i] = Py_NewRef(o);
    }
    #line 2010 "Python/generated_cases.c.h"
    DISPATCH();
}

处理完之后,localplus 的布局如下,注意:此时是内层函数对应的 localplus。

图片图片

在构建内层函数时,会将 cell 变量打包成一个元组,交给内层函数的 func_closure 字段。然后执行内层函数创建栈帧的时候,再将 func_closure 中的 cell 变量拷贝到 localsplus 的第三段内存中。当然对于内层函数而言,此时它应该叫做 free 变量。

而在调用内层函数 inner 的过程中,当引用外层作用域的符号时,一定是到 localsplus 里面的 free 区域(第三段内存)去获取对应的 PyCellObject *,然后通过内部的 ob_ref 进而获取符号对应的值。至于 name 和 age 分别对应哪一个 PyCellObject,这些都体现在字节码指令参数当中了。

然后我们再来看看 free 变量是如何加载的,它由 LOAD_DEREF 指令完成。

TARGET(LOAD_DEREF) {
    PyObject *value;
    #line 1453 "Python/bytecodes.c"
    // 加载 PyCellObject *
    PyObject *cell = GETLOCAL(oparg);
    // 获取 PyCellObject 对象的 ob_ref 字段的值
    value = PyCell_GET(cell);
    if (value == NULL) {
        format_exc_unbound(tstate, frame->f_code, oparg);
        if (true) goto error;
    }
    Py_INCREF(value);
    #line 1980 "Python/generated_cases.c.h"
    STACK_GROW(1);
    stack_pointer[-1] = value;
    DISPATCH();
}

这里再补充一点,我们说 localplus 是一个连续的数组,只是按照用途被划分成了四个区域:保存局部变量的内存空间、保存 cell 变量的内存空间、保存 free 变量的内存空间、运行时栈。

但对于当前的内层函数 inner 来说,它是没有局部变量和 cell 变量的,所以 localsplus 开始的位置便是 free 区域。

当然不管是局部变量、cell 变量,还是 free 变量,它们都按照顺序保存在 localplus 中,并且在编译阶段便知道它们在 localsplus 中的位置。比如我们将内层函数 inner 的逻辑修改一下。

图片图片

在 inner 里面创建了三个局部变量,那么它的字节码会变成什么样子呢?这里我们直接看 print 函数执行时的字节码即可。

图片图片

因为 inner 里面没有函数了,所以它不存在 cell 变量,里面只有局部变量和 free 变量。

图片图片

所以虽然我们说 localplus 被分成了四份,但是 cell 区域和 free 区域很少会同时存在。对于外层函数 some_func 来说,它没有 free 变量,所以 free 区域长度为 0。而对于内层函数 inner 来说,它没有 cell 变量,所以 cell 区域长度为 0。

只有函数的里面存在内层函数,并且外面存在外层函数,那么它才有可能同时包含 cell 变量和 free 变量。

但为了方便描述,我们仍然认为 localplus 被分成了四个区域,只不过对于外层函数 some_func 而言,它的 free 区域长度为 0;对于 inner 函数而言,它的 cell 区域长度为 0。

当然这些都是概念上的东西,大家理解就好。但不管在概念上 localplus 怎么划分,它本质上就是一个 C 数组,是一段连续的内存,用于存储局部变量、cell 变量、free 变量(这三种变量不一定同时存在),以及作为运行时栈。

最重要的是,这三种变量都是基于数组实现的静态访问,并且怎么访问在编译阶段就已经确定,因为访问数组的索引会作为指令参数存储在字节码指令集中。

这便是静态访问。

小结

本篇文章我们就介绍了闭包,比想象中的要更加简单。因为闭包仍是一个函数,只是将外层作用域的局部变量变成了 cell 变量,然后保存在内部的 func_closure 字段中。

然后执行内层函数的时候,再将 func_closure 里的 PyCellObject * 拷贝到 localplus 的 free 区域,此时我们叫它 free 变量。但不管什么变量,虚拟机在编译时便知道应该如何访问指定的内存。

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

免责声明:

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

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

软考中级精品资料免费领

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

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

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

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

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

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

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