《python解释器源码剖析》第10章--python虚拟机中的一般表达式
10.0 序
上一章中,我们通过PyEval_EvalFrameEx看到了python虚拟机的整体框架,那么这一章我们将深入到PyEval_EvalFrameEx的各个细节当中,深入剖析python的虚拟机,在本章中我们将剖析python虚拟机是如何完成对一般表达式的执行的。这里的一般表达式包括最基本的对象创建语句、打印语句等等。至于if、while等表达式,我们将其归类于控制流语句,对于python中控制流的剖析,我们将留到下一章。
10.1 简单内建对象的创建
# a.py
i = 1
s = "py"
d = {}
l = []
我们在a.py的foo函数中创建了几个简单的对象,我们分析一下其字节码的含义。不过在此之前我们需要看一些宏,这是PyFrame_EvalFrameEx在遍历指令序列co_code时所需要的宏,里面包括了对栈的各种操作,以及对tuple元素的访问操作。
/* Tuple access macros */
//获取tuple中的元素
#ifndef Py_DEBUG
#define GETITEM(v, i) PyTuple_GET_ITEM((PyTupleObject *)(v), (i))
#else
#define GETITEM(v, i) PyTuple_GetItem((v), (i))
#endif
//调整栈顶指针
#define BASIC_STACKADJ(n) (stack_pointer += n)
#define STACKADJ(n) { (void)(BASIC_STACKADJ(n), \
lltrace && prtrace(TOP(), "stackadj")); \
assert(STACK_LEVEL() <= co->co_stacksize); }
//入栈操作
#define BASIC_PUSH(v) (*stack_pointer++ = (v))
#define PUSH(v) BASIC_PUSH(v)
//出栈操作
#define BASIC_POP() (*--stack_pointer)
#define POP() ((void)(lltrace && prtrace(TOP(), "pop")), \
BASIC_POP())
我们来看一下这个a.py的字节码,使用dis模块来分析一下
2 0 LOAD_CONST 0 (1)
2 STORE_NAME 0 (i)
3 4 LOAD_CONST 2 ('py')
6 STORE_NAME 1 (s)
4 8 BUILD_MAP 0
10 STORE_NAME 2 (d)
5 12 BUILD_LIST 0
14 STORE_NAME 3 (l)
16 LOAD_CONST 0 (None)
18 RETURN_VALUE
第一列显然表示行号,对应源文件的2、3、4、5行
我们来看看字节码指令如何影响当前活动的PyFrameObject对象中的运行时栈和local命名空间(f->f_locals)
字节码指令对符号或者常量的操作最终都会反应到运行时栈和local命名空间中
我们来看一下运行时栈和local空间的初始情况,在local空间中将存储执行过程中的局部变量,同时它也是python虚拟机的局部变量表,说白了就是一个PyDictObject对象,存储一个个变量名:变量值的键值对。

我们下面来仔细分析一下字节码
2 0 LOAD_CONST 0 (1)
2 STORE_NAME 0 (i)
我们发现源代码为2,对应了两条字节码,说明赋值这个动作实际上是两个指令
TARGET(LOAD_CONST) {
PyObject *value = GETITEM(consts, oparg);
Py_INCREF(value);
PUSH(value);
FAST_DISPATCH();
}
GETITEM(consts, oparg)显然是GETITEM(consts, 0),就是从consts中读取索引为0的这个元素
然后将其压入虚拟机的运行时栈当中,其实这个consts就是f->f_code->co_consts
其中f是当前活动的PyFrameObject对象,那么其实consts也就是PyCodeObject里面的co_consts
这里面只包含了常量值,因此我们也会把consts称之为常量表
这是第一行字节码,显然在执行完毕之后只改变了运行时栈,并没有改变local。
但是不用想也知道,执行i = 1这个操作应该在local命名空间中创建一个从符号i到PyLongObject对象1之间的映射,这样我们后面才能通过符号i找到其对应的对象
python虚拟机通过执行字节码指令STORE_NAME来改变local命名空间,从而完成变量名i到变量值1之间的映射
TARGET(STORE_NAME) {
//从符号表中获取符号,显然oparg=0
PyObject *name = GETITEM(names, oparg);
//从运行时栈中获取值,
PyObject *v = POP();
//拿到f_locals
PyObject *ns = f->f_locals;
int err;
//类型检测
if (ns == NULL) {
PyErr_Format(PyExc_SystemError,
"no locals found when storing %R", name);
Py_DECREF(v);
goto error;
}
//将符号、值的映射关系存储到local命名空间中
if (PyDict_CheckExact(ns))
err = PyDict_SetItem(ns, name, v);
else
err = PyObject_SetItem(ns, name, v);
Py_DECREF(v);
if (err != 0)
goto error;
DISPATCH();
}
现在我们发现变量名和变量值在内存中是通过一种怎样的关系捆绑在一起的了,注意:由于我们从运行时栈中获取值执行的是pop操作,所以此时运行栈中不存在任何对象了
然后第二行代码存储字符串和第一行是一样的,只不过参数不同罢了,此时如下:

但是在源代码的第四行,我们看到了有意思的东西
4 8 BUILD_MAP 0
10 STORE_NAME 2 (d)
字节码偏移8这里不再是load了,因为和int、str不同,不会直接load,虚拟机在执行该字节码指令时直接创建了一个PyDictObject对象,然后压入运行时栈中
TARGET(BUILD_MAP) {
Py_ssize_t i;
PyObject *map = _PyDict_NewPresized((Py_ssize_t)oparg);
if (map == NULL)
goto error;
for (i = oparg; i > 0; i--) {
int err;
PyObject *key = PEEK(2*i);
PyObject *value = PEEK(2*i - 1);
err = PyDict_SetItem(map, key, value);
if (err != 0) {
Py_DECREF(map);
goto error;
}
}
while (oparg--) {
Py_DECREF(POP());
Py_DECREF(POP());
}
PUSH(map);
DISPATCH();
}
然后执行STORE_NAME

然而对于最后一行代码,居然出现了4条字节码。
5 12 BUILD_LIST 0
14 STORE_NAME 3 (l)
16 LOAD_CONST 0 (None)
18 RETURN_VALUE
对于BUILD_LIST和BUILD_MAP类似
TARGET(BUILD_LIST) {
PyObject *list = PyList_New(oparg);
if (list == NULL)
goto error;
while (--oparg >= 0) {
PyObject *item = POP();
PyList_SET_ITEM(list, oparg, item);
}
PUSH(list);
DISPATCH();
}
可以推测,如果源代码创建的不是一个空的list,那么在BUILD_LIST指令前,一定会有许多LOAD_CONST操作,这将导致有许多对象被压入运行时栈中,在真正执行BUILD_LIST时,再将这些对象从栈里面一一弹出来,加入到新创建的PyListObject对象当中,因为这些对象其实就是list中的元素。
然后在执行完STORE_NAME,将变量名l和值映射之后,按理说就应该结束了,但是为什么还多了两行呢?
其实python在执行完一段代码块的时候,一定要范围一些,这两个字节码就是用来返回某些值的。可以看到LOAD_CONST将None给load进来,然后RETURN_VALUE返回回去。
TARGET(RETURN_VALUE) {
retval = POP();
why = WHY_RETURN;
goto fast_block_end;
}
在所有字节码指令都执行完毕之后,运行时栈就变空了,但所有信息都存储到了local命名空间中。

10.2 复杂内建对象的创建
我们前面看到了,python创建空的dict、空的list的过程。那么如果创建非空的dict和list,python运行时行为又是怎么样的呢?
#a.py
i = 1
s = "python"
d = {"1": 1, "2": 2}
l = [1, 2]
显然对于符号表(names, f->f_code->co_names)来说,在运行期间和之间应该是一样的,而常量表(consts,f->f_code->co_consts)来说则是不同的。
2 0 LOAD_CONST 1 (1)
2 STORE_NAME 0 (i)
3 4 LOAD_CONST 2 ('python')
6 STORE_NAME 1 (s)
4 8 LOAD_CONST 1 (1)
10 LOAD_CONST 3 (2)
12 LOAD_CONST 4 (('1', '2'))
14 BUILD_CONST_KEY_MAP 2
16 STORE_NAME 2 (d)
5 18 LOAD_CONST 1 (1)
20 LOAD_CONST 3 (2)
22 BUILD_LIST 2
24 STORE_NAME 3 (l)
26 LOAD_CONST 0 (None)
28 RETURN_VALUE
首先源代码的第2、3行和之前一样,我们看源代码的第4行,我们看到了3个LOAD_CONST,表示将两个值和所有的keyload进来,然后BUILD_CONST_KEY_MAP比较重要,后面有一个2,这个2表示的是要创建的字典里面有两个元素
TARGET(BUILD_CONST_KEY_MAP) {
Py_ssize_t i;
PyObject *map;
PyObject *keys = TOP();
if (!PyTuple_CheckExact(keys) ||
PyTuple_GET_SIZE(keys) != (Py_ssize_t)oparg) {
PyErr_SetString(PyExc_SystemError,
"bad BUILD_CONST_KEY_MAP keys argument");
goto error;
}
map = _PyDict_NewPresized((Py_ssize_t)oparg);
if (map == NULL) {
goto error;
}
for (i = oparg; i > 0; i--) {
int err;
PyObject *key = PyTuple_GET_ITEM(keys, oparg - i);
PyObject *value = PEEK(i + 1);
err = PyDict_SetItem(map, key, value);
if (err != 0) {
Py_DECREF(map);
goto error;
}
}
Py_DECREF(POP());
while (oparg--) {
Py_DECREF(POP());
}
PUSH(map);
DISPATCH();
}
会根据load进来的values和keys创建PyDictObject对象,然后STORE_NAME,这样一个dict对象就创建完成了。
同理对于list的创建就更简单了,同样是先load每一个元素,并压入运行时栈,然后BUILD_LIST,创建完PyListObject对象,再从运行时栈中依次将对象取出,塞入PyListObject对象维护的"列表"中。最后STORE_NAME。然后load None、返回
10.2.1 函数中的变量
我们之前的变量是在模块级别的作用域中,但如果我们在函数中定义呢?
def foo():
i = 1
s = "python"
2 0 LOAD_CONST 1 (1)
2 STORE_FAST 0 (i)
3 4 LOAD_CONST 2 ('python')
6 STORE_FAST 1 (s)
8 LOAD_CONST 0 (None)
10 RETURN_VALUE
我们看到大致一样,但是有一点发生了变化, 那就是在将变量名和变量值映射的时候,使用的不再是STORE_NAME,而是STORE_FAST,显然STORE_FAST会更快一些。为什么这么做,这是因为函数中的局部变量总是固定不变的,在编译的时候就能确定局部变量使用的内存空间的位置,也能确定局部变量字节码指令应该如何访问内存,就能使用静态的方法来实现局部变量。其实局部变量的读写都在fastlocals = f->f_localsplus上面
TARGET(STORE_FAST) {
PyObject *value = POP();
SETLOCAL(oparg, value);
FAST_DISPATCH();
}
#define SETLOCAL(i, value) do { PyObject *tmp = GETLOCAL(i); \
GETLOCAL(i) = value; \
Py_XDECREF(tmp); } while (0)
#define GETLOCAL(i) (fastlocals[i])
10.3 一般表达式
符号搜索
a = 5
b = a
c = a + b
print(c)
还是a.py,里面写了一些简单的语句,我们来看看它的字节码如何。
import dis
f = open("a.py", "r", encoding="utf8").read()
dis.dis(f)
"""
1 0 LOAD_CONST 0 (5)
2 STORE_NAME 0 (a)
2 4 LOAD_NAME 0 (a)
6 STORE_NAME 1 (b)
3 8 LOAD_NAME 0 (a)
10 LOAD_NAME 1 (b)
12 BINARY_ADD
14 STORE_NAME 2 (c)
4 16 LOAD_NAME 3 (print)
18 LOAD_NAME 2 (c)
20 CALL_FUNCTION 1
22 POP_TOP
24 LOAD_CONST 1 (None)
26 RETURN_VALUE
"""
首先源代码第一行无需介绍,但是第二行我们发现,不再是LOAD_CONST,而是LOAD_NAME,其实也很好理解。第一行a = 5, 而5是一个常量所以是LOAD_CONST,但是b = a,这里的a是一个变量名,所以是LOAD_NAME。
//这里显然要从几个命名空间里面去寻找指定的变量名对应的值
//找不到就会出现NameError
TARGET(LOAD_NAME) {
//从符号表里面获取变量名
PyObject *name = GETITEM(names, oparg);
//获取local命名空间的里面键值对
PyObject *locals = f->f_locals;
PyObject *v;
if (locals == NULL) {
PyErr_Format(PyExc_SystemError,
"no locals when loading %R", name);
goto error;
}
//根据变量名从locals里面获取对应的value
if (PyDict_CheckExact(locals)) {
v = PyDict_GetItem(locals, name);
Py_XINCREF(v);
}
else {
v = PyObject_GetItem(locals, name);
if (v == NULL) {
if (!PyErr_ExceptionMatches(PyExc_KeyError))
goto error;
PyErr_Clear();
}
}
//如果v是NULL,说明local命名空间里面没有
if (v == NULL) {
//于是从global命名空间里面找
v = PyDict_GetItem(f->f_globals, name);
Py_XINCREF(v);
//如果v是NULL说明global里面也没有
if (v == NULL) {
//下面的if和else里面的逻辑基本一致,只不过对builtin做了检测
if (PyDict_CheckExact(f->f_builtins)) {
//local、global都没有,于是从builtin里面找
v = PyDict_GetItem(f->f_builtins, name);
//还没有,NameError
if (v == NULL) {
format_exc_check_arg(
PyExc_NameError,
NAME_ERROR_MSG, name);
goto error;
}
Py_INCREF(v);
}
else {
//从builtin里面找
v = PyObject_GetItem(f->f_builtins, name);
if (v == NULL) {
//还没有,NameError
if (PyErr_ExceptionMatches(PyExc_KeyError))
format_exc_check_arg(
PyExc_NameError,
NAME_ERROR_MSG, name);
goto error;
}
}
}
}
//找到了,把v给push进去,相当于压栈
PUSH(v);
DISPATCH();
}
另外如果是在函数里面,这里b = a,那么就既不是LOAD_CONST、也不是LOAD_NAME,而是LOAD_FAST。这是因为函数中的变量在编译的时候就已经确定,因此是LOAD_FAST。那么如果a=5定义在函数外面呢?那么结果是LOAD_GLOBAL,因为知道这个a到底是定义在什么地方。
数值运算
再来看看c=a+b的字节码
3 8 LOAD_NAME 0 (a)
10 LOAD_NAME 1 (b)
12 BINARY_ADD
14 STORE_NAME 2 (c)
插一嘴,我们看到5这个数值只被load了一次,当b=a的时候,只是将b指向了a,说明a和b都指向了同一个PyLongObject对象。那么它们是如何结合的呢?
python虚拟机首先会通过两条LOAD_NAME指令将变量名a和b所对应的变量值从local命名空间读取出来,压入运行时栈,然后通过BINARY_ADD进行加法运算,计算两个变量的和。假设计算之后的结果为sum,那么python在获得结果sum之后,会通过STORE_NAME将('c', sum)插入到local命名空间。当然这里只是加法,当然减法、乘法等等也是类似的。我们再来看看那个BINARY_ADD
TARGET(BINARY_ADD) {
//获取两个值,也就是我们a和b对应的值
PyObject *right = POP();
PyObject *left = TOP();
PyObject *sum;
//这里检测是否是字符串
if (PyUnicode_CheckExact(left) &&
PyUnicode_CheckExact(right)) {
//是的话直接拼接
sum = unicode_concatenate(left, right, f, next_instr);
}
else {
//不是的话相加
sum = PyNumber_Add(left, right);
Py_DECREF(left);
}
Py_DECREF(right);
//设置sum
SET_TOP(sum);
if (sum == NULL)
goto error;
DISPATCH();
}
信息输出
最后看看信息是如何输出的
4 16 LOAD_NAME 3 (print)
18 LOAD_NAME 2 (c)
20 CALL_FUNCTION 1
22 POP_TOP
24 LOAD_CONST 1 (None)
26 RETURN_VALUE
由于我们print(c),显然需要把print和c压入栈中。这里的print也是LOAD_NAME,因为我们可以自己也定义一个变量叫做print,如果我们没有定义一个叫做print的变量,那么得到的就是python里面用于打印的print。
CALL_FUNCTION,表示函数调用,执行刚才的print,后面的1则是参数的个数。另外,当调用print的时候,实际上又创建了一个栈帧,因为只要是函数调用都会创建栈帧的。
TARGET(CALL_FUNCTION) {
PyObject **sp, *res;
sp = stack_pointer;
res = call_function(&sp, oparg, NULL);
stack_pointer = sp;
PUSH(res);
if (res == NULL) {
goto error;
}
DISPATCH();
}
然后POP_TOP表示从栈的顶端把元素打印出来,这里显然是c的值。最后LOAD_CONST、RETURN_VALUE,无需解释了。
最后再来看看print是如何打印的
//Objects/bltinmodule.c
static PyObject *
builtin_print(PyObject *self, PyObject *const *args, Py_ssize_t nargs, PyObject *kwnames)
{
//python里面print支持的参数
static const char * const _keywords[] = {"sep", "end", "file", "flush", 0};
static struct _PyArg_Parser _parser = {"|OOOO:print", _keywords, 0};
//初始化全部为NULL
PyObject *sep = NULL, *end = NULL, *file = NULL, *flush = NULL;
int i, err;
if (kwnames != NULL &&
!_PyArg_ParseStackAndKeywords(args + nargs, 0, kwnames, &_parser,
&sep, &end, &file, &flush)) {
return NULL;
}
if (file == NULL || file == Py_None) {
file = _PySys_GetObjectId(&PyId_stdout);
//默认输出到sys.stdout也就是控制台
if (file == NULL) {
PyErr_SetString(PyExc_RuntimeError, "lost sys.stdout");
return NULL;
}
/* sys.stdout may be None when FILE* stdout isn't connected */
if (file == Py_None)
Py_RETURN_NONE;
}
if (sep == Py_None) {
sep = NULL;
}
else if (sep && !PyUnicode_Check(sep)) {
PyErr_Format(PyExc_TypeError,
"sep must be None or a string, not %.200s",
sep->ob_type->tp_name);
return NULL;
}
if (end == Py_None) {
end = NULL;
}
else if (end && !PyUnicode_Check(end)) {
PyErr_Format(PyExc_TypeError,
"end must be None or a string, not %.200s",
end->ob_type->tp_name);
return NULL;
}
for (i = 0; i < nargs; i++) {
if (i > 0) {
if (sep == NULL)
//设置sep为空格
err = PyFile_WriteString(" ", file);
else
//否则说明用户了sep
err = PyFile_WriteObject(sep, file,
Py_PRINT_RAW);
if (err)
return NULL;
}
err = PyFile_WriteObject(args[i], file, Py_PRINT_RAW);
if (err)
return NULL;
}
//end同理,不指定的话默认是打印换行
if (end == NULL)
err = PyFile_WriteString("\n", file);
else
err = PyFile_WriteObject(end, file, Py_PRINT_RAW);
if (err)
return NULL;
//flush则是否强制刷新控制台
if (flush != NULL) {
PyObject *tmp;
int do_flush = PyObject_IsTrue(flush);
if (do_flush == -1)
return NULL;
else if (do_flush) {
tmp = _PyObject_CallMethodId(file, &PyId_flush, NULL);
if (tmp == NULL)
return NULL;
else
Py_DECREF(tmp);
}
}
Py_RETURN_NONE;
}
思考题
为什么在python2中,while 1:比while True:要快,用上面的知识很容易完美解答,首先这里提示一下:python2中的True不是一个关键字。
答:因为在python2中True不是一个关键字,这就意味着我们可以使用True作为一个变量名,因此python会检测这个True这个名字有没有人用,如果local、global命名空间里面都没有的话,那么最后再去builtin里面拿到表示bool的True,显然每一次循环都要进行这样的检测。但对于while 1就不一样的,1是一个整数、而且还是小整数对象池里面的整数,这就说明直接LOAD_CONST即可,因为不可能拿一个数字当变量名,数字就是一个常量,没有True那些检测、查询的过程,所以while 1:比while True:要快。但是在python3中,这两者是一样的,因为True已经是一个关键字了,全局唯一,并且它是继承自int的,所以也是LOAD_CONST。
《python解释器源码剖析》第10章--python虚拟机中的一般表达式的更多相关文章
- 《python解释器源码剖析》第12章--python虚拟机中的函数机制
12.0 序 函数是任何一门编程语言都具备的基本元素,它可以将多个动作组合起来,一个函数代表了一系列的动作.当然在调用函数时,会干什么来着.对,要在运行时栈中创建栈帧,用于函数的执行. 在python ...
- 《python解释器源码剖析》第9章--python虚拟机框架
9.0 序 下面我们就来剖析python运行字节码的原理,我们知道python虚拟机是python的核心,在源代码被编译成字节码序列之后,就将有python的虚拟机接手整个工作.python虚拟机会从 ...
- 《python解释器源码剖析》第11章--python虚拟机中的控制流
11.0 序 在上一章中,我们剖析了python虚拟机中的一般表达式的实现.在剖析一遍表达式是我们的流程都是从上往下顺序执行的,在执行的过程中没有任何变化.但是显然这是不够的,因为怎么能没有流程控制呢 ...
- 《python解释器源码剖析》第0章--python的架构与编译python
本系列是以陈儒先生的<python源码剖析>为学习素材,所记录的学习内容.不同的是陈儒先生的<python源码剖析>所剖析的是python2.5,本系列对应的是python3. ...
- 《python解释器源码剖析》第13章--python虚拟机中的类机制
13.0 序 这一章我们就来看看python中类是怎么实现的,我们知道C不是一个面向对象语言,而python却是一个面向对象的语言,那么在python的底层,是如何使用C来支持python实现面向对象 ...
- 《python解释器源码剖析》第1章--python对象初探
1.0 序 对象是python中最核心的一个概念,在python的世界中,一切都是对象,整数.字符串.甚至类型.整数类型.字符串类型,都是对象.换句话说,python中面向对象的理念观测的非常彻底,面 ...
- 《python解释器源码剖析》第8章--python的字节码与pyc文件
8.0 序 我们日常会写各种各样的python脚本,在运行的时候只需要输入python xxx.py程序就执行了.那么问题就来了,一个py文件是如何被python变成一系列的机器指令并执行的呢? 8. ...
- 《python解释器源码剖析》第4章--python中的list对象
4.0 序 python中的list对象,底层对应的则是PyListObject.如果你熟悉C++,那么会很容易和C++中的list联系起来.但实际上,这个C++中的list大相径庭,反而和STL中的 ...
- 《python解释器源码剖析》第7章--python中的set对象
7.0 序 集合和字典一样,都是性能非常高效的数据结构,性能高效的原因就在于底层使用了哈希表.因此集合和字典的原理本质上是一样的,都是把值映射成索引,通过索引去查找. 7.1 PySetObject ...
随机推荐
- xiaopiu产品原型设计与团队实时协作平台
PRD文档创作 全新的文档创作模式,让交互原型与产品文档完美结合: 四大专业模板,满足多场景使用,快速输出专业规范的文档 PRD文档搜索 更专业.更精准的PRD文档垂直搜索服务,包含功能流程.协议条款 ...
- .Netcore 2.0 Ocelot Api网关教程(3)- 路由聚合
在实际的应用当中,经常会遇到同一个操作要请求多个api来执行.这里先假设一个应用场景:通过姓名获取一个人的个人信息(性别.年龄),而获取每种个人信息都要调用不同的api,难道要依次调用吗?在Ocelo ...
- 自动化运维:(3)写一个简单的Shell脚本(案例)
一.需求 1.test.sh 脚本执行时候需要添加参数才能执行 参数和功能详情如下: 参数 执行效果 start 启动中... stop 关闭中... restart 重启中... * 脚本帮助信息. ...
- Django-Form组件-formset_factory
Formset 多个表单的集合,可以同时提交多个from表单中的数据,在web页面中,可以在同一个页面,提交多个form表单. Django针对不同的formset提供了3种方法: formset_f ...
- 【VS开发】使用CTabView分割多页卡窗口
一般书中介绍的是使用CSplitterWnd来拆分窗口实现多视图,CSplitterWnd中的CreateClient可以保存其创建的pCreateContext指针,以便子视图共享Document. ...
- Python smtplib发邮件
常用邮箱SMTP.POP3域名及其端口号 发送普通文本内容的邮件 import smtplib from email.header import Header from email.mime.text ...
- PTA (Advanced Level)1082.Read Number in Chinese
Given an integer with no more than 9 digits, you are supposed to read it in the traditional Chinese ...
- 二叉树(Java实现)
一.常见用语 1.逻辑结构:描述数据之间逻辑上的相关关系.分为线性结构(如,字符串),和非线性结构(如,树,图). 2.物理结构:描述数据的存储结构,分为顺序结构(如,数组)和链式结构. 3.结点的度 ...
- 小白windows上搭建linux环境
我使用的oracle VM VirtualBox,下载使用就好了 这是用的虚拟机,不是搭建linux系统,不用担心把电脑搞坏,游戏打不了 全程很简单,基本都是默认,下一步 下一步 默认下一步 创建 下 ...
- echart4数据管理组件dataset学习
背景 如果后台数据固定,如何动态定制其前端数据展示方式呢?也就是说同一种数据,如何被多个前端Echarts图表复用呢?最近在研究一种数据展示可配置化的功能,然后发现了echart4.0的dataset ...