C++VSpython系列:类型篇1

  随着学习的深入,笔者逐渐接触到了数据分析,人工智能,网络攻防等方面的知识,仅仅掌握C/C++已经不够了,因此开启本系列文章,事先声明,本系列不是引战的,在笔者看来,语言没有好与坏,只有适不适合。取这个名称是因为笔者掌握的是C/C++,未掌握的是Python,从已知推未知是非常合理的学习策略,通过对比的角度来学习,能够同时加深对两种语言的理解,一举多得,首先来介绍一下两种语言基础语法的不同。

基础语法

  在正式分析语法差别时,必须先补充一个前置知识,那就是解释型语言与编译型语言的区别:python是解释型语言,而C++是编译型语言,对于编译型语言,要将程序员编写的程序变为可执行程序必须进行预处理,编译,汇编,连接那一套,最终得出可执行文件,也叫ELF文件或是目标文件,显然,编译型语言的操作就像是一个翻译书籍的翻译员,在出书前,必须把整本书的每一个字都翻译好,但是解释型语言不同,解释型语言执行程序的主要流程是使用解释器,直接运行程序员编写的源码,在运行时,一句句的解释成机器码,解释型就像是看书时随身携带了一个翻译员,你问一句,翻译员回一句。从此处可以看出编译型语言的效率是更高的,直接就把整份代码翻译成机器码了,并且对于可执行文件编译器,连接器等程序会进行大量的优化,而解释型语言不存在可执行文件,又或者说程序员写的源代码就是解释型语言的可执行文件,在运行时才由解释器一句句的把指令翻译成机器码,想象一下,你在看书时是问翻译员一句看一句,还是直接看翻译好后的书快,显然是直接看翻译好后的书更快,因此一般来说编译型语言的运行效率比解释型语言要高,不过python的解释器有专门的优化,所以也差不了多远,解释型语言的专属优势就是灵活,跨平台非常方便,因为编译型语言是把代码翻译成机器码后才执行文件,然而不同架构的机器能看懂的机器码是不同的,因此就必须针对不同的平台写出不同的代码,这就像是要把一本英文书籍卖向全球,那么首先就必须翻译成多个不同的版本,而解释型语言就像是随书自带一个翻译员,无论是什么平台,只要安装了解释器,就可以直接运行不同平台的代码,说人话就是解释器帮程序员把跨平台给做好了,并且解释型语言一般还会提供GC(内存回收机制),不会存在C/C++中恶心的内存泄漏。两种类型的语言各有优缺点,一般而言在靠近底层,对性能要求极高的场景下(比如游戏引擎,嵌入式开发)用到的就是编译型语言,如果是对性能要求没那么高的场景,又想要快速便利的进行开发的话,那么使用解释型语言就比较好了,不过在现代编程中,一般都是多种语言混用的,像游戏引擎中也存在Lua一类的"胶水语言",总之对于一个有点经验的程序员,掌握4到5门语言都是非常基础的,并且在开发的过程中还会不断接触到新的语言,新的技术,这或许也是程序员这一行的魅力之一吧,无穷无尽的变化,各种各样的新事物。但是要记住的是无论是什么样的语言,无论是多么花哨的技术,其底层的思想,设计与架构思路是相通的,如果只拘泥于表层的语言,技术,函数调用...,那么你学一辈子都入不了编程的门,学一辈子都学不完各种各样的技术,优秀程序员的一项基本技能就是透过技术看到思想,这样就能够起到一通百通的效果,即:技术只是表像,唯有思考永恒,因此本系列的文章会由浅入深,最终分析到语言设计的层面,下面让我们开始吧。

1.变量与类型

1.1C++和Python变量的底层差别

  来看看下面两段代码:

python 复制代码
#python
number = 10
salary = 3.5
hight = 50
name = "Jack"
phone = '1234123'
cpp 复制代码
//cpp
int number = 10;
float salsry = 3.5;
size_t hight = 50;
std::string name = "Jack";
auto phone = "1234234";

  首先在python中注释使用的是#,一般而言脚本语言注释使用的都是#,在C++中,注释使用的是//,然后就是比较神奇的事情了,在python中定义变量不需要指定变量类型,只需要变量名称,这里的水非常深,我们得仔细的分析一下了,首先我们理解的是在C++中的变量简单来说可以认为就是对一个小内存块的取的名字,严谨的说是:由编译器管理的,有类型信息,有生命周期的内存抽象 ,比如定义了一个变量int a = 10后,我们在有效作用域下就可以使用a这个变量名称访问到一个虚拟地址空间中的内存块,在通过页表映射后会对应上物理内存中的一个4字节内存块,但是在实际使用该变量时,编译器会严格的检查a的类型,就像是4字节的内存可以存浮点类型,int类型,无符号int类型,编译器就通过类型名称来严格的确定变量名在内存中对应的类型,也可以认为类型就是编译器看待内存的视角,比如float a和int a就是完全不同的两个类型的变量,尽管它们占用的都是4字节,但是编译器认为它们完全是不同的东西,并且在使用变量时,编译器严格的按照作用域进行分析,比如局部的变量就绝不能在全局中使用。由于变量类型是编译器看待内存的视角,是变量的具体属性,因此在C++中定义变量当然就必须带上具体的类型了,不然编译器就"瞎了",并且在C++中的变量是在编译期就确定好的,类型检查也是在编译期进行的,属于静态类型,说人话就是在你给出变量声明后,这个变量的类型就被定死了,比如int a,在后续所有的使用中就只能是int,不能莫名其妙的变成其它类型,除非你类型转换了,从这个角度看,所谓的类型转换就是告诉编译器使用新的视角看待变量。不过现代的C++也提供了dynamic_cast能够在运行时进行类型转换,不过本系列主要是讲python的,这里就不展开了,总之在C++中定义一个变量必须携带类型,具体流程就像是编译器先根据变量的类型在内存中造出一个大小固定的"盒子"(这个盒子在编译期就造好了),然后把值存到盒子里面,最后再给这个盒子起名为你定义的变量名,这就是变量名约等于内存块的底层机制。

  下面让我们来看看在python中定义变量为什么不需要指定具体的类型,简单来说,C++与python在设计变量与类型系统是采用的是完全不同的思路,C++的设计思路是变量名即内存块的抽象,变量类型是编译器看待内存块的视角,而python的变量在底层主要是使用指针实现的,当你在python中定义了一个a = 10时,在底层会先创建一个对象,可以认为是类对象,在该对象中就存储了变量的类型信息和值信息,然后让变量名使用指针指向该对象,可以认为是这样的{'a': 对象指针},在python中这叫字典,功能比较像C++中的哈希表,'a'就是key值,对象指针就是value值,这意味着在python中,变量名就只是一个单纯的名称,不涉及类型绑定,不像在C++中定义一个int a = 10时,a的类型就被绑定死了,在python中只需要更改'a'映射的指针,就可以改变一个变量名对应的底层对象了,也就是说在python中可以这样:

python 复制代码
a = 10
print(a)

a = "12345"
print(a)

a = [1, 2, 3]
print(a)

  最后的\[\]是python中的容器类型,叫列表,这段代码在C/C++程序员的眼中是非常神奇的,因为在C/C++中这就像是:

cpp 复制代码
int a = 10;
a = "12345";
a = vector<int>(3, 5);

  从int转型成string,甚至是vector,这是绝对不可能的事情,但是在python中可以,根本原因就在于python的类型机制:变量名不绑定类型,变量名就仅仅只是一个名称,在底层只需要让变量名映射到不同的地址就可以实现上方那种神奇的效果了,上方的过程甚至不涉及类型转换,底层只是指针的改变,并且python中的类型属于动态类型,因为在底层是使用指针实现的类型系统,而且是C语言的指针,身为C/C++程序员,我们都非常清楚指针在运行时才会解引用定位到具体的对象,底层使用指针就决定了python表层的类型体系的动态的,可以认为在python程序没有运行起来前,python解释器是完全不知道一个变量具体是什么类型的,而上文又说明了python是解释器语言,代码是解释一行运行一行的,因此只有真正的运行到a = 10这一行代码时,解释器通过变量名映射的指针找到具体的类型对象时,解释器才能够确定真正的类型是什么,可以说绑定类型的是底层的对象,表层的变量名就是一个纯粹的名称。

1.2Python中的内置类型

  python中的内置类型主要分为六类,分别是:

类型
1.数值类型:int, float, bool, complex
2.序列类型:str, list, tuple, range,bytes, bytearry,memoryview
3.映射类型:dict
4.集合类型:set, frozenset
5.空值类型:NoneType
6.其它类型:type, object, function, module, class
1.2.1简单内置类型

  本节将详细的介绍其中的每一个类型并分析python底层的类型继承体系,下面先来看看数值类型,首先是我们熟悉的int,与C++中一样int是有符号整形,可以存储负数与正数,但是与C++不同的是,python的int没有大小限制,其底层是动态扩容的,而C++的int是固定的4字节大小,表示范围有限,比如python中的int可以这样:

python 复制代码
a = 99999999999999999999999999999999999999999999999999999999999

  后面跟多少个9都可以,只要你的电脑存的下,用起来非常爽,但是底层的内存分配情况是非常复杂的,下面来详细的分析一下python中的int在底层是怎么实现的,首先在上文就说明过了,不像C++中类型是程序员告诉编译器看待变量的视角,python中的类型信息是绑定在底层的对象上的,这个对象就是类对象,也就是说在python中的a = 10在底层就创建了一个对象,绑定类型的对象之间还存在复杂的继承关系,而类型表现出来的神奇特性就封装在底层的对象中,要深入理解底层原理,就必须去分析python的解释器,python的官方解释器叫CPython,是完全使用C语言实现的,这就是属于我们C/C++程序员的领域了,理解了解释器,自然就会比那些只学习表层python语法的纯python程序员强几个档次,下面来看看int类型创建的对象在CPython中的源代码:

c 复制代码
typedef struct {
    Py_ssize_t ob_refcnt;           // 引用计数(8字节,64位系统)
    PyTypeObject *ob_type;          // 类型指针(8字节)
    Py_ssize_t ob_size;             // 位数/符号(8字节)
                                    // |ob_size| = 数字的"位数"
                                    // ob_size < 0 表示负数
    digit ob_digit[1];              // 以 2^30 为基数的数字数组
} PyLongObject;

  很显然是一个结构体,第一个成员是一个引用计数,出现引用计数,那么容易推测出在只读情况下,下方的代码在底层指向的应该是同一个对象:

python 复制代码
a = 10
b = a
c = a
d = a

证明如下:

python 复制代码
print(b is a)
print(c is a)
print(d is a)

  在理解了底层后,我们很容易就可以分辨出python中的"is"和"=="的区别,显然is判断的是两个变量映射的指针在底层有没有指向同一个对象,而双等号是判断表层变量的值是否相等的,下面来验证一下:

python 复制代码
#1
a1 = 10
b1 = a1
b1 = 20

print(b1 is a1)
print(b1 == a1)


#2
a2 = 20
b2 = 20
c = a2

print(c is a2)
print(b2 == a2)
print(b2 is a2)

  先来自己判断一下,在#1中的a1 = 10在底层就是创建了一个int类型的对象,然后让变量名a映射对象的指针,然后是b1 = a1,此时b1与a1在底层必然指向的是同一个对象,然后注意到b1修改了值,那么就发生类似于C/C++中的写时拷贝,在底层立刻创建出了一个新的int类型对象并存储了值20,如果将变量b1映射的指针修改为新创建对象的指针,因此下方的两个print都是false,然后是#2,a2于b2的值相同,但是由于显示的写明了要两个对象,因此b2 is a2会打印出false,然后是c = a2,那么显然c is a2就会打印出true了,而b2于a2的值相等,因此 b2 == a2会打印出true,最终的结果就是ture,ture,false,下面来看看实际结果:

  嗯,最后的b2 is a2错了,那么我们有理由推断,对于int类型,如果变量名指定对应的值是相同的,那么在底层会让这些变量名映射到同一个指针上,解释器使用引用计数来确定有多少个变量名引用了底层的对象,来简单的验证一下:

python 复制代码
a = 10000000
b = 10000000
c = 10000000
d = 10000000

print(a is b)
print(c is d)
print(d is a)
print(b is d)

结果如我们所料,那么还容易推断出在int类型中如果其中一个变量指定的值发生了变化,在底层会触发类似于写实拷贝行为(python中实际上没有写时拷贝),让该变量名映射到新创建的对象上:

python 复制代码
a = 10000000
b = 10000000
c = 10000000
d = 10000011

print(b is a)
print(c is a)
print(b is c)
print(d is a or d is b or d is c)

结果如我们所料,上方代码中的or就相当于C++中的||,还值得一提的是,在python中是允许"d is d"的,返回值永远为true。下面让我们考虑这种情况:

python 复制代码
a = 10
b = 20

c = 10
print(c is a)

c = 20
print(c is b)

  首先第一个print肯定是打印出true的,但是第二个就比较迷了,我们无法确定解释器是直接将变量名c映射到已存在相同值对象(b映射的底层对象)上还是新创建出一个对象,来看看运行的结果:

  这说明了两件事情:1.python在底层非常重视节省内存,2.在只读时,解释器放的是非常"宽"(其实是解释器优化)。在纯python程序员中一个非常容易混淆的概念对于我们C/C++程序员来说就像玩一样,so easy,并且还容易推测出,当双等号的判断为false时,is的判断也必然为false,因为底层的一个对象不能同时存储两个不同的值,但是当is的判断为false时,双等号的判断是可以为true的,因为底层两个不同的对象可以拥有相同的值。

  如果每创建一个指定不同值的变量解释器在底层都要创建出一个对应的对象,然后再让变量名映射到该对象的指针的话,那么在频繁创建不同int值的变量时效率是一定会比较低的,不过由于引用计数的存在,如果一个对象在底层已经创建好了,那么在语言层在创建对应该对象值的变量时效率就是非常高的,甚至比C++还高,因为解释器要做的就仅仅是让变量名映射到对象的指针上并让底层对象的引用计数加1,连内存都不用申请,也就是说可以认为在对象已经存在时,变量的创建就是纯引用。python的解释器充分的利用了这个特点,为int类型设计出了小整数缓存机制缓存机制,缓存整数范围是-5, 256,解释器在底层预先为这个范围内的每一个值都提前创建好了对应的对象(就像是C++中的对象池),然后在语言层使用小整数时就是纯引用,底层不需要创建对象。由引用计数我们其实还可以一窥python中GC(内存自动回收)的原理,这东西其实非常像C++中的shared_ptr,底层使用引用计数,当引用计数归零时自动释放申请的内存,在小整数缓存范围内的对象可以认为就是引用计数永不归零,在语言层只要使用了小整数,那么就是纯引用,典型的以空间换时间的池化技术。

  从一个小小的引用计数就可以推理出这么多信息,这就是我们C/C++程序员独有的优势,下面来看看int对象中的第二个字段:

c 复制代码
PyTypeObject *ob_type;          // 类型指针(8字节)

这个成员变量显然就是记录实际类型信息的了,是一个结构体指针,指向的是另一个解释器封装的结构体,我们有理由推测在结构体中就记录着int类型的所有元数据,在这里要补充的一点是在python中一切皆对象,类型本身也是对象,在底层的体现就是一个所有类型都存在专门的结构体来封装类型对应的元数据和方法,不像C++中的int就是告诉编译器要如何看待一个4字节的内存块,int本身仅仅只是传递给编译器信息的,是类型信息的硬编码,几乎不占用任何空间,python中的int也是对象,占用空间,拥有复杂的元数据和方法,可以认为就像是pthon中的内置类型也和C++中自定义类型一样,是非常复杂的,解释器中int类型的底层实现就是:

c 复制代码
typedef struct _typeobject {
    PyObject_HEAD                    // 类型对象本身也是对象
    const char *tp_name;             // 类型名称,如 "int"
    Py_ssize_t tp_basicsize;         // 创建实例时的基本大小
    Py_ssize_t tp_itemsize;          // 可变部分每个元素的大小
    
    // 方法指针(类似 C++ 虚函数表)
    destructor tp_dealloc;         // 析构函数
    reprfunc tp_repr;                // repr() 实现
    PyNumberMethods *tp_as_number;   // 数值运算方法(+-*/等)
    PySequenceMethods *tp_as_sequence; // 序列方法(索引、切片等)
    PyMappingMethods *tp_as_mapping;   // 映射方法
    
    // 属性访问
    struct PyMethodDef *tp_methods;  // 方法定义表
    struct PyMemberDef *tp_members;  // 成员定义表
    struct PyGetSetDef *tp_getset;  // 属性 getter/setter
    
    // 类型继承
    struct _typeobject *tp_base;     // 基类指针(如 int 的基类是 object)
    
    // ... 更多字段
} PyTypeObject;

  所以才说python在表层用的爽都是因为解释器在负重前行,复杂的工作别人已经帮我们处理好了,用的当然就爽了,一个小小的int类型在底层就封装了这么多的元数据,我们先关注tp_name字段,这个字段就是python中的type方法能够打印出类型名称的关键所在,比如:

python 复制代码
a = 10
b = "12345"
c = 1.23

print(type(a))
print(type(b))
print(type(c))

  显然type在底层读取的就是tp_name中的字符串信息然后再打印出来,然后是destructor tp_dealloc;字段,该函数就是int类型的析构函数,int类型的GC就是靠这个实现的,当引用计数归零时就调用析构函数,和shared_ptr的实现逻辑是类似的。下面来讲解一下python中int类型的另一个核心机制,即存储数字大小无上限的原理。

  其实我们C/C++程序员也可以封装出一个动态扩容的类来模拟出python中int类型的行为,甚至是直接使用vector配合上指定规则的位运算也可以达到类似的效果,不是什么神奇的东西。而在python中是使用C语言中的柔性数组实现了int的动态扩容,也就是int类型中的这个字段:

c 复制代码
digit ob_digit[1];              // 以 2^30 为基数的数字数组

在\[\]中填了1是为了兼容老的C标准,标准柔性数组中的\[\]是什么都不填或填0的,在python中可以认为当你定义了大整数时,在底层就会先计算出需要的内存大小,然后利用柔性数组来存储大整数,最后配合上位运算在数组中读取出原本的数值,注释信息的意思就是数组中的每一个元素的大小都是2302^{30}230符号位是存储在"Py_ssize_t ob_size;"字段中的,在柔性数组中存储都是正数,准确的说是绝对值,还容易发现在底层中一个int应该是32字节的,就算去掉最高位的符号位也应该是2312^{31}231才对,这应该是为了便于位运算和运算的进位才,并且还容易推测出在python中的大整数运算时间复杂度绝对不是O(1)的,因为涉及到了数组的遍历,大数运算的规则必然也是非常复杂的,这里就不多分析了,再往下就变成纯C语言源码分析了,本系列的主角毕竟还是python的,下面来看看"float"类型。

  这个类型我们也不陌生,不过注意到python中没有double,因此可以判断出float应该是双精度的,直接就把double给取代了,直接来看看float在底层的对象:

c 复制代码
typedef struct {
    PyObject_HEAD          // 引用计数 ob_refcnt + 类型指针 ob_type
    double ob_fval;        // 实际的 double 值
} PyFloatObject;

  把引用计数和执行类型信息的指针封装起来了,然后就是一个简单的double,非常简单,没什么复杂的机制,就是把C中的double套了层壳,这意味着直接使用float的话也是会出现精度丢失问题的。下面来看看bool,这个类型我们也不陌生,直接看看底层的实现:

c 复制代码
typedef PyLongObject PyBoolObject;
struct _longobject _Py_FalseStruct = {
    PyObject_HEAD_INIT(&PyBool_Type)
    .ob_digit = { 0 }    // 值为 0
};

struct _longobject _Py_TrueStruct = {
    PyObject_HEAD_INIT(&PyBool_Type)
    .ob_digit = { 1 }    // 值为 1
};

显然bool底层的对象复用了int对象,准确的说是用C语言模拟出来的继承的行为,bool就继承了int,还注意到创建bool的函数是static的,这说明bool类型是单例,全局唯一,下面来验证一下:

python 复制代码
print(False + True)
print(True + True)
print(type(True))
print(type(True+True))

  结果如我们所料,可以认为True底层就是一个值为1的int对象的封装,而False在底层就是一个值为0的int对象封装,在单独使用时其类型是bool,由int小整数缓存的机制也容易推断True和False是全局唯一的,下面来看看complex,这个类型在C++中是没有的,是数学中的复数,就是这个:z=a+biz = a + biz=a+bi,其使用方法如下:

python 复制代码
a = 2 + 4j
print(type(a))
print(a.real)  #实部
print(a.imag)  #虚部

a += 10
print(a)

a -= 3j
print(a)

一般来说在实际编程中都不会使用到这个类型,在C++中没有这个内置类型也没什么影响,简单的看看其底层实现:

c 复制代码
typedef struct {
    double real;        // 实部
    double imag;        // 虚部
} Py_complex;

typedef struct {
    PyObject_HEAD       // 引用计数 + 类型指针
    Py_complex c_val;   // 复数值(两个 double)
} PyComplexObject;

  实部和虚部都是double类型的,其它的就没什么好说的了,数值类型就分析完了,最难的反而是看起来简单的int,并且底层的实现明显是具有统一性的,对于我们C/C++程序员来说没什么难度,最后来说一个没什么用但有意思的操作,在python中变量名可以是中文的:

python 复制代码
你 = 10
好 = 20
世 = 30
界 = 40
print(你, 好, 世, 界)

这说明python中的变量名是按照字符串进行存储的,在构造中英文词典时这个操作或许会有点用,下面来看看序列类型的情况。

1.2.2列表(list)

  python的对于序列的标准定义是:一种可迭代,支持高效元素访问并且支持整数索引的对象,身为C/C++程序员,在看到"可迭代","高效访问"和"整数索引"时应该就能够感觉出序列是一个很牛逼的东西了,因此在具体讲解类型前,有必要先讲解一下序列的常用操作,此处以列表list为例,首先列表的基本构造方法:

python 复制代码
a = [1, 2, 3, 4, 5]
b = list((1, 2, 3, 4, 5))

c = ["12345"]
d = list("12345")

print(a)
print(b)
print(c)
print(d)

  使用\[\]进行构造就像是隐式的调用了构造函数,要构造的元素之间使用逗号间隔开,然后就是显示的使用list进行构造,唯一要注意的就是字符串的隐式构造和显示构造表现出了不同的行为,下面来看一个比较高级的构造方法,即列表推导式:

python 复制代码
a = [i for i in range(10)]
#等价于:
# a = []
# for i in range(10):
#     a.append(i)

b = [(j, k) for j in range(10) for k in range(10)]
#等价于:
# b = []
# for j in range(10):
#     for k in range(10):
#         b.append((j, k))

print(a)
print(b)

在构造列表时还可以使用乘法构造,又或者是使用列表推导式的嵌套:

python 复制代码
a = [0]*5

b = [[0 for _ in range(3)] for _ in range(3)]

print(a)
print(b)

  在讲到底层时再谈具体细节,总之python中构造列表的方法主要就是:\[\]构造,list()构造,乘法构造和列表推导式,下面来介绍一下列表常用的方法(非常像vector),首先是增,具体方法如下:

python 复制代码
= [1, 2]

a.append(5)
print(a)

a.append([1,2,3])
print(a)

a.append("123")
print(a)

a.append((1, 3))
print(a)

  append就是尾插一个元素,操作没什么好说的,而且时间复杂度明显是O(1)的,不涉及数据的移动,不过注意到在同一个列表中可以有不同类型的数据,甚至是包含序列类型,底层使用模板可做不到这个神奇的效果,因此我们可以推断在底层是使用指针实现的,也就是说在list中存储的多半是指针,指针指向各种不同类型的数据,比如可以是使用下标映射到指针,就像是这样:{1 : ptr},和数值类型的底层实现应该是类似的,当然此处只是推测,在后文笔者会结合源代码详细分析,下面来介绍下一种添加元素的方法:

python 复制代码
a = [1, 2]

a.extend([3,4,5])
print(a)

a.extend("abc")
print(a)

a.extend(range(6, 9))
print(a)

  显然extend是在尾部追加可迭代对象中的所有元素,涉及到了遍历添加指定可迭代对象,因此时间复杂度是O(k)的,不过每次操作都是有效操作,因此实际上其实没什么影响,然后是下一种添加方法:

python 复制代码
a = [1, 2]

a += [1, 2, 3]
print(a)

a += "123"
print(a)

显然在底层调用的就是extend,没什么好说的,来看看下一种添加方法:

python 复制代码
a = [1, 2]

a.insert(0, 4)
print(a)

a.insert(-1, 9)
print(a)

a.insert(1, [1, 2, 3])
print(a)

  显然insert就是往指定的位置插入元素,遵循的原则就是插入后元素的下标变为指定的下标,要注意的是python中是可以使用负数访问列表的,-1就代表最后一个的下标,而且如果想要使用insert进行尾插是不能指定下标为最后一个元素的下标的(python中下标从0开始),比如:

python 复制代码
a = [1, 2, 3]

a.insert(2, 8)
print(a)

  由于insert是被插入元素的下标是指定插入的下标,因此如果指定最后一个元素的下标进行插入是无法实现尾插的,可以看到8的下标是2,但是并不是最后一个元素,原因就是插入元素后列表中在插入元素后方的元素的下标都会加1,想要实现尾插的话可以这样:

python 复制代码
a = [1, 2, 3]

a.insert(3, 8)
print(a)

a.insert(100, 9)
print(a)

  insert不会越界,因此直接指定一个很大的下标也可以,此时默认就是尾插,当然你也可以指定最后一个元素下标+1的值作为下标,显然易见的是insert的时间复杂度是O(n)的,在头插的情况下涉及到了所有元素的移动,并且也只能插入单个对象。总结一下列表的插入操作就是:

插入操作
append:尾插单个元素,时间复杂度O(1)
extend:将一个可迭代元素中的所有元素迭代插入列表尾部,时间复杂度O(k)
+=:行为和extend一样
insert:在指定下标插入元素,插入元素的下标成为指定下标,时间复杂度O(n)

下面来看看删除操作:

python 复制代码
a = [1, 2, 3, 3, 8, 8, 8, 9]
print(a)

a.remove(8)
print(a)

a.remove(9)
print(a)

a.remove(3)
print(a)

  显然remove的行为是删除在列表中第一个出现的指定元素,时间复杂度是O(n)的,然后是:

python 复制代码
a = [1, 2, 3, 3, 8, 8, 8, 9]
 
b = a.pop()
print(a)
print(b)

c = a.pop(0)
print(a)
print(c)

d = a.pop(2)
print(a)
print(d)

  显然pop的默认行为是弹出列表中尾部的元素,可以使用变量接收弹出的元素,默认行为的时间复杂度是O(1)的,如果指定了值,那么pop就会以指定值为下标,弹出指定下标处的值,时间复杂度是O(n)的,然后来看看最后一个删除操作:

python 复制代码
a = [1, 2, 3, 3, 8, 8, 8, 9]
print(a)

a.clear()
print(a)

显然clear的行为就是清空列表,时间复杂度是O(1)的(涉及到了底层原理,后文详细介绍),总结一下列表的删除操作就是:

删除
remove:删掉列表中第一个与指定值相同的元素,如果指定的值不再列表中,那么会在运行时报错,时间复杂度O(n)
pop:默认行为是弹出列表尾部元素,时间复杂度O(1),可以指定要弹出元素的下标,此时时间复杂度就是O(n)
clear:清空列表,时间复杂度O(1)

  下面来看看查找操作:

python 复制代码
a = [1, 2, 3, 3, 8, 8, 8, 9]

i1 = a.index(3)
print(f"index: {i1}, value: {a[i1]}")

i2 = a.index(8, 3, 6)
print(f"index: {i2}, value: {a[i2]}")

  显然index的行为是查找指定元素第一次出现的下标,可以在第二第三个参数中指定查找范围,指定的是下标范围,并且是左闭右开的,没有指定范围时就默认查找整个列表,要注意的是如果在范围内没有出现要查找的元素,那么会在运行时报错,显然时间复杂度是O(n)的,下面来看看下一个查找方法:

python 复制代码
a = [1, 2, 3, 3, 8, 8, 8, 9]

b = a.count(3)
c = a.count(8)
d = a.count(100)
print(f"b: {b}")
print(f"c: {c}")
print(f"d: {d}")

  显然count的行为是统计指定元素在列表中出现的次数,从1开始计数,如果指定元素不在列表中就返回0,显然时间复杂度是O(n)的,总结一下列表的查找方法有:

查找
index:查找指定元素在列表指定范围中第一次出现的下标,如果不指定范围那么就默认在查找整个列表的所有元素,如果在范围内不存在指定元素,那么就会在运行时报错,时间复杂度是O(n)
count:查找指定元素在列表中出现的次数,从1开始计数,如果元素不存在,那么就返回0,时间复杂度是O(n)

下面来看看列表的修改操作:

python 复制代码
a = [1, 2, 3, 3, 8, 8, 8, 9]

a.sort()
print(a)

a.sort(reverse=True)
print(a)

b = ["123", "1234", "12345"]
b.sort(key=len, reverse=True)
print(b)

  显然sort的默认行为是按照升序将列表中的元素进行排序,时间复杂度是O(nlogn)的,底层使用了插入排序和归并排序的结合体,我们可以指定key来自定义排序方法,和C++中函数对象的效果差不多,还可以指定reverse为True来进行降序排序,来看看下一个修改操作:

python 复制代码
a = [1, 2, 3, 3, 8, 8, 8, 9]
a.reverse()

print(a)

显然就是将列表中的所有元素反转,和C++中reverse的行为是一样的,时间复杂度是O(n)的,至此列表的增删查改操作就全部介绍完了,还存在的一个操作是拷贝,该操作涉及到底层深浅拷贝,因此在讲解底层原理时在详细介绍,下面来总结一下增删查改的方法:

总结
append:尾插元素
entend:遍历一个可迭代对象中的所有元素进行尾插
insert:在指定下标处插入指定元素,插入后新元素的下标成为指定的下标
remove:删除在列表中第一个出现的指定元素,如果元素不在列表中就会在运行时报错
pop:弹出指定下标位置的元素,如果不指定就默认弹出列表尾部元素
clear:清空列表
index:查找指定元素在指定范围内第一次出现的下标并返回,如果在指定范围内不存在指定元素,那么就会在运行时报错
count:统计指定元素在列表中的个数
sort:默认行为是将列表排为升序,如果指定reverse=True,那么就按照降序排序,如果指定了key,那么就按照key提供的方法进行排序
reverse:反转列表元素

  笔者其实不喜欢写纯语法的介绍,这东西AI就能写,而且写的还非常全面,我们要重点关注的应该是列表的底层设计思路与实现,这是C/C++程序员独有的优势,自然要全面的发挥出来,首先从列表中能够填充任意类型的元素容易判断出底层多半是使用下标来映射对象的指针,也就是说在列表中实际存储的元素多半都是这样的:{下标 : 指针},比如下方的列表:

python 复制代码
a = [1, 2, [1, 2, 3]]

该列表在内存中的布局多半是:

来看看解释器的源代码:

c 复制代码
typedef struct {
    PyObject_VAR_HEAD           // 变长对象头部(包含 ob_refcnt, ob_type, ob_size)
    PyObject **ob_item;          // 指向元素指针数组的指针(动态数组)
    Py_ssize_t allocated;        // 当前分配的容量(非元素个数)
} PyListObject;

  注意到ob_item是一个二级指针,这印证了笔者的猜测,一个list在底层的数组中存放的就是指针,也就是说是一个指针数组,该二级指针就是数组首元素的指针,也就是指向指针的指针,那么自然就是二级指针了,在数组中的每一个指针都指向底层创建的实际对象,我们在访问列表时实际上就是使用指针的解引用操作找到对应的底层类型对象,在频繁的创建列表对象时,底层也有一套提高效率的机制,来看看下方的代码:

c 复制代码
#define PyList_MAXFREELIST 80

static PyListObject *free_list[PyList_MAXFREELIST];  // 缓存池
static int numfree = 0;                                 // 当前缓存数量

  有一个列表的缓存池,要注意的时不是预先创建一批列表对象,而是当用户将列表释放后,如果缓存池中缓存列表的数量小于80,那么就会将列表中指向的对象释放,把列表本身字段保留(相当于保留了一个struct PyListObject的壳,其中的ob_item是空指针),把保留的这个壳给缓存在free_list中,在创建列表时会先检测是否存在缓存的列表,如果存在就直接从缓存池中把壳给取出来复用,也就是说存放实际元素的底层对象还是要创建的,但是列表的壳不需要创建,验证如下:

python 复制代码
a = [1, 2, 3]
a_id = id(a)

del a

b = [1,2,3,4,5]
print(id(b) == a_id)

print(f"id_b = {id(b)}")
print(f"id_a = {a_id}")

  这短短的五行代码笔者敢说会让大部分纯python程序员摸不着头脑,就算能够判断出来多半也是死记硬背的,因为底层全是用C语言和指针实现的,首先id的效果就是取出底层对象的唯一标识,在python中是这么说的,在我们C/C++程序员眼中其实就是取出了对象的地址,一个简单的取地址操作(python中有意弱化了指针的概念,因为在python看来指针太复杂了),因此上方代码的含义就是先创建出列表对象a,然后取出底层对象的地址,然后将底层对象释放掉,此时如果列表的那层壳也被释放了,那么下次创建出来的就是全新的对象,地址几乎不可能是相同的,但是如果壳被缓存起来了,那么新创建列表就会复用,地址就会是相同的,从结果来看显然是底层的列表壳缓存机制生效了,对于我们C/C++程序员来说没什么难的。列表缓存机制在python语言层体现出来的应用就是:不用的列表就赶快释放掉,尽管python有GC,但是如果一个列表一直都不释放,那么就是变相的造成了内存泄漏,既然列表本质上存储的是指向对象的指针,那么容易推测在将一个列表拷贝给新的变量时多半是走浅拷贝的,也就是只是让变量映射到了底层的同一个列表对象,那么一个变量修改了列表就应该会影响另一个变量映射的列表:

python 复制代码
a = [1, 2, 3]
b = a

b[0] = 100
print(a)

  结果如我们所料,b的修改也影响了a,在C++中b = a是标准的拷贝写法,如果对于底层有指向堆区资源的对象采用浅拷贝,那么就会将同一块内存释放两次,从而导致UB行为,但是在python中对象的释放完全是由解释器进行的,是可控的(其实在C++中也可以做到,只是风险比较大),因此可以使用浅拷贝进行列表的拷贝,此时a和b就类似于是这样的:

也可以使用is来看看a和b是不是真的指向同一个底层的对象:

python 复制代码
print(a is b)

自然而然的,肯定会存在需要深拷贝的场景,也就是让变量引用的底层对象完全不同,此时可以使用python中的copy库:

python 复制代码
import copy
a = [1, 2, 3]
b = copy.deepcopy(a)

b[0] = 100
print(a)
print(b)
print(a is b)

此时在底层的内存布局就是这样的:

当列表深度只有一层时,使用列表自带的copy方法也可以实现深拷贝,即:

python 复制代码
a = [1, 2, 3]
b = a.copy()

b[0] = 100
print(a)
print(b)
print(a is b)

此时deepcopy和copy的效果是完全相同的,但是当列表的深度超过一层时(列表中嵌套列表),deepcopy会递归深拷贝所层的对象,而copy只会深拷贝第一层的对象,比如:

python 复制代码
from copy import deepcopy
a = [1, 2, [1, 2, 3, [4, 5]]]
b = a
c = a.copy()
d = deepcopy(a)

print(b is a)   #1
print(b[2] is a[2])   #2
print(b[2][3] is a[2][3])   #3

print(c is a)   #4
print(c[2] is a[2])   #5
print(c[2][3] is a[2][3])   #6

print(d is a)   #7
print(d[2] is a[2])   #8
print(d[2][3] is a[2][3])   #9

b[2][0] = 5
a[0] = 10
b[2][3][1] = 90

c[0] = 8
c[2][2] = 100

d[1] = 5
d[2][3] = [1, 2]

print(a)  #10
print(b)  #11
print(c)  #12
print(d)  #13

问上方代码的执行结果是什么?首先我们画出a,b,c,d的内存视图:

  然后就是简单的看图说话了,首先b在底层就等价于a,指向的壳和对象都是完全相同的,因此1,2,3处的print显然都是True,然后是4处的,使用copy时会把壳和第一层的对象都给拷贝走,因此是Fasle,但是copy不会拷贝第一层的壳,c2和a2指向同一个对象,因此5处是True,那么显然6处也是True了,然后是使用的deepcopy出来的d,其在底层就是递归拷贝了a的所有对象,因此d和a在底层除了对象存储的值相等外也没什么关系了,那么7,8,9处的print就显然是Fasle了,所以上半部分的结果就是:True3,False,True 2,False*3,来看看实际情况:

  一模一样,这就是C/C++程序员独有的底层视角,是程序员的内功,内功练好了再来学习其它技术和语言自然就会比没练过内功的人理解的更加深刻。现在来分析第二部分的结果,首先是这三条代码:

python 复制代码
b[2][0] = 5
a[0] = 10
b[2][3][1] = 90

a和b在底层完全就是同一个对象,因此修改的也是同一个东西,得出:

python 复制代码
a = b = [10, 2, [5, 2, 3, [4, 90]]]

然后是下面的代码:

python 复制代码
c[0] = 8
c[2][2] = 100

此时c拷贝了a和b的壳和第一层,因此第一处修改第一层的c0不会影响到a和b,但是第二处的c22修改的就是第二层的数据了(可以简单记为一个\[\]就代表一层),c的第二层和a,b是同一个对象,因此结果为:

python 复制代码
c = [8, 2, [5, 2, 100, [4, 90]]]
a = b = [10, 2, [5, 2, 100, [4, 90]]]

最后的代码是:

python 复制代码
d[1] = 5
d[2][3] = [1, 2]

d和a,b,c在底层完全是不同的对象,因此上方的修改完全不影响d,d的修改同样也不会影响a,b,c因此最终的结果就是:

python 复制代码
c = [8, 2, [5, 2, 100, [4, 90]]]
a = b = [10, 2, [5, 2, 100, [4, 90]]]
d = [1, 5, [1, 2, 3, [1, 2]]]

打印的结果就是:

python 复制代码
[10, 2, [5, 2, 100, [4, 90]]]
[10, 2, [5, 2, 100, [4, 90]]]
[8, 2, [5, 2, 100, [4, 90]]]
[1, 5, [1, 2, 3, [1, 2]]]

看看实际结果:

  下面来看看列表其它特性:从列表能够动态在添加元素就可以判断出列表底层是使用动态数组的,也就是在堆区申请内存创建数组,那么和C++中的使用vector的原则就是类似的,比如:

python 复制代码
a = []
for i in range(1000):
    a.append(i)    #底层需要多次扩容,效率低

b = [None]*1000  #预分配内存,相当先开辟好空间再填入数据
for i in range(1000):
    b[i]  

c = [i for i in range(1000)]   #底层做了特殊处理,效率最高,相当于在开辟空间是直接创建对象

最后来介绍一下列表中一个强大且常用的功能,即切片操作,基本语法是start:stop:step,start指定切片起到,stop指定切片终点,step指定步长,先来看看基本的用法:

python 复制代码
a = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
b = a[0:5]   
print(b is a)
print(b)

可以发现切片操作的start和stop遵循左闭右开原则,也就是把[start : stop)部分的所有元素都切出来,而且在底层显然是深拷贝了,那么修改b就应该不会影响a:

python 复制代码
b[0] = 100

print(a)
print(b)

单层如此,来验证多层列表时的情况:

python 复制代码
a = [[0, 1, 2, [3, 4]], 5, 6, 7, 8, 9, 10]
b = a[:] 

print(a)
print(b)
print(b is a)
print(b[0] is a[0])
print(b[0][3] is a[0][3])

:就是对整个列表进行切片,显然切片赋值的行为和copy是一样的,只拷贝壳和第一层,不会像deepcopy一样走递归的深拷贝,这意味着如果b对深于第一层的值进行修改,就会影响到a:

python 复制代码
b[0][0] = 1000
print(a)

整体拷贝如此,下面来看看局部拷贝的行为:

python 复制代码
a = [[0, 1, 2, [3, 4]], 5, 6, 7, 8, 9, 10]
b = a[0:3]

print(b[0] is a[0])
print(b[0][3] is a[0][3])

b[0][1] = 99
print(a)

显然在切片中进行的拷贝操作和copy是完全相同的,无论的整体的还是局部的,容易判断出切片在底层是使用指针实现的,而且多半是双指针,最后来说一些切片的常用操作,首先是切片具体的切法:

python 复制代码
a = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

print(a[::2])
print(a[::-1])
print(a[0:100])
print(a[0:])
print(a[-5::2])
print(a[-5:-1])
print(a[2::2])
print(a[:5])
print(a[3:8:-2])
print(a[-6:-2:2])

在python中对于下标的使用和C++有点不同,具体来说python中允许下标是负数,上方列表a的下标就是这样的:

  在切片中同样可以使用负数下标,切片的基本原则是切片一定会从start开始,到stop结束,并且不会取到stop下标的值,因为有效范围遵循左闭右开,即start,stop),在指定负数start或stop时,底层会先转换为正数下标,下面来具体的分析上方的切片操作首先是a\[::2,由于切片的基本语法是start:stop:step,因此a::2就是不指定start和stop,只指定步长step为2,要注意的是当没有指定start和stop时,python会根据步长的正负情况为start和stop填充不同的默认值,在步长为正数时start默认为0,stop默认为len(a),负数的情况我们下面再谈,不过又因为指定了步长为2,因此就是从0下标开始每隔一个元素取一次,这就像是从下标零开始取,然后往后走两步:0+2=2,取下标2处的,然后再走两步:2+2=4,取下标4的,显然在最后一步时有可能会出现走出有效范围的情况,此时就不会取元素,而不是默认取最后一个元素,比如:

python 复制代码
b = [1, 2, 3]
print(b[::2])
print(b[::5])

b::2就是从0下标开始取,第一次就取出了元素1,然后往后走两步到下标2,没有越界,因为stop默认是len(a),值为3,而2小于3,所以越界,要注意的是一但走到的值大于或等于stop时就会判定为越界,直接停止切片,因此取出有效元素3,然后再走就越界了(2+2=4),底层直接结束循环,对于b::5就是从下标0开始取,取出元素1,然后往后走五步,直接超出stop,底层结束循环,不会默认把最后一个元素3给取出来,会到例子a中,a::2显然就是在列表范围的[0, len(a))中取元素,每次走两步,结果就是:

python 复制代码
a[::2] = [0, 2, 4, 6, 8, 10]

然后是a::-1,步长指定负数就会倒着走,此时依旧是从start开始,到stop结束,不会反过来从stop开始,所以此时python为start填充的默认值是len(a)-1,即最后一个元素的下标,为stop填充的值是-1,要注意的是-1是结束标志,不表示最后一个值的下标,因为有效范围严格满足start,stop),-1是取不到的,实际上取到的范围是\[len(a)-1,0,即从结尾元素的正下标开始取,每次让len(a)-1加上步长值(负数),不断变小,直到小于或等于stop时就结束,总之只需要记住:**1.切片操作严格遵循从start开始往stop走;2.有效范围是start,stop);3.每次让start加上step(带符号的),等于或越过stop时结束切片;4.如果指定的start或stop为负数,那么底层会先把其转换为对应下标处的正数;5.当步长的符号不同时,默认给start和stop的初始值是不同的**,在step为正数时大于stop是越过,step为负数小于step是越过,要注意的是,笔者理解的\[start,stop)不是严格遵守数学中的start\填充默认start和stop后就是a9: -1 :-1,有效范围是[9,-1),步长为-1,即从9下标开始,每次-1,直到等于或小于stop,也就是第一次取下标9元素,第二次取下标8元素...,结果就是:

python 复制代码
a[::-1] = [10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]

然后是a0:100没有指定步长,那么步长默认为1,要注意的是不能指定步长为0,会在运行时报错,此时start指定了0,stop指定了100,要说明的是如果指定的stop超过了有效范围,那么会默认切片到有效元素的末尾,不会报错,因此结果就是:

python 复制代码
a[0:100] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

然后是a0:,不指定stop就默认切片到有效元素的末尾,因此结果是:

python 复制代码
a[0:] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

然后是a-5::2,此时指定start是-5,得先把start转换为对应的正数,我们先来推导一下转换方法,得出通用的结论,考虑下图:

基于该通用情况,开始推导从负数下标转为正数下标是算法,显然易见的存在关系:负数下标-正数下标 = -n,因为实际上正数下标和负数下标都是线性递增的,而且增速相同,因此对应差值必然相同,由此得出公式:正数下标 = 负数下标 + n,其中n为列表元素数。带入数据得出-5下标转换为正数下标是方法为:正数下标 = -5 + 11 = 6,又由于步长为正数,因此stop默认值为len(a),即11,填充后得出a6:11:2,取出的有效下标就是:6, 8, 10,结果为:

python 复制代码
a[-5::2] = [6, 8, 10]

然后是a-5:-1,在不指定步长时,step默认为1,将start和stop转换为正数,得出start = 11 - 5 = 6,stop = 11 - 1 = 10,完整填充后得出a6:10:1,因此结果为:

python 复制代码
a[-5:-1] = [6, 7, 8, 9]   #[-5, -1)

然后是a2::2,这个没什么难的,完整填充后得出a2:11:2,结果就是:

python 复制代码
a[2::2] = [2, 4, 6, 8, 10]

然后是a:5,完整填充后得出a0:5:1,因此结果是:

python 复制代码
a[:5] = [0, 1, 2, 3, 4]  #左闭右开[0, 5)

然后是a3:8:-2,同时指定了start,stop和step,首先step是-2,那么从start开始走永远都走不到8,此时python直接返回空集,也就是说如果无法从start走到stop的话,那么python直接返回空集,连start处的元素都不会取,具体体现就是在步长为正数时,如果start > stop(start原本就比stop大,再加上正数就比stop更大了),那么返回空集,在步长为负数时,如果start < stop(start原本就比stop小,再加上负数就比stop更小了),那么返回空集,因此结果为:

python 复制代码
a[3:8:-2] = []  

最后是a-6:-2:2,我们先把start和stop转为正数,得出start = 11 - 6 = 5,stop = 11 - 2 = 9,填充后得出a5:9:2,那么就会取到下标5,7(注意:不会取到9),因此结果为:

python 复制代码
a[-6:-2:2] = [5, 7]

来看看实际结果:

切片操作起来非常灵活,但是只要记住笔者上方提出的那5条原则,即:那么一般情况下都不会出什么问题的。下面来看看下一个序列。

1.2.3字符串(str)

同样的,先来看看字符串的常见构造方法:

python 复制代码
s1 = 's1 = hello'
s2 = "s2 = hello"
s3 = """s3 = hello
hello
hello\n"""

s4 = str('s4 = hello wrold')
s5 = str([1, 2, 3])
s6 = str(123) 

s7 = "".join("hello")
s8 = f"s8 = {s7} wrold"

print(s1)
print(s2)
print(s3)
print(s4)
print(s5)
print(s6)
print(s7)
print(s8)

在python中,单引号和双引号是等价的,也可以认为在python中不存在字符的概念,只存在字符串,单个字符同样被视为字符串,这也是在简单类型中没有char的原因,三引号是创建多行字符串用的,不过其实单双引号配合换行符也可以做到,因此三引号用的不多,然后就是显示的调用str构造字符串,没什么好说的,最后是两个比较特殊的构造字符串方式,即join和f"{}",join是将指定字符串添加到空字符串中,要注意的是如果字符串中原本就有内容的话,那么join的行为就会变得很奇怪,在下方介绍str的方法时在详细说明,最后是f"{}",这是一个非常强大的字符串格式化方法,只要在有""的地方,其前方一般都可以加上f,然后在""内部的{}中就可以填入变量,变量对应的值就会被格式化成字符串,比如:

python 复制代码
a = [1, 2, 3, [4, 5]]
s = f"{a}"
print(a)

一般来说直接使用单引号或双引号再配合上f就可以完成大部分字符串的构造需求了,而且这种形式的构造效率也是最高的,值得一提的是在python中的字符串同样是不可修改的,也就是说不允许:

python 复制代码
s = "12345"
s[1] = '1'

编译可以通过,但是运行时直接报错,python的序列总体还分为两类,即可变序列和不可变序列,目前我们已知的是列表list是可变序列,字符串str是不可变序列,str常用的方法非常多,比如大小写转换,查找子串等,笔者就不一一介绍了,这里仅仅演示一些最常用的方法,要提前说明的一点是,在python中如果在引号内部嵌套了相同的引号,那么就会发生引号冲突,也就是解释器无法确定要匹配哪一个引号,比如下方的写法就是错误的:

python 复制代码
print(f"s.find: {s.find("lo")}")   #err

在双引号中嵌套了双引号,解释器无法匹配,这种情况只需要把引号内部的引号改为不同的引号就可以了,比如双引号中嵌套单引号:

python 复制代码
print(f"s.find: {s.find('ol')}")  #ok

str常用的方法如下:

python 复制代码
s = "     hEllo wrolld hE    "

#大小写转换
print(f"s.upper: {s.upper()}")            #转换为全大写
print(f"s.lower: {s.lower()}")            #转换为全小写
print(f"s.swapcase: {s.swapcase()}")      #大小写互转
print(f"s.capitalize: {s.capitalize()}")  #字符串首字母转为大写,其它字母都变为小写
print(f"s.title: {s.title()}\n")            #字符串中每个单词的首字母转为大写,单词的其它字母转为小写

#查找与替换
print(f"s.find: {s.find('ol')}")          #从左往右查找子串,如果找到那么返回子串中首字母在原字符串中的下标,如果没找到就返回-1
print(f"s.find: {s.find('U')}")

print(f"s.index: {s.index('lo')}")        #从左往右查找字串,如果找到那么返回子串中首字母在原字符串中的下标,如果没找到就在运行时抛出异常
print(f"s.rfind: {s.rfind('oL')}")        #从右往左查找子串,如果找到那么返回子串中首字母在原字符串中的下标,如果没找到就返回-1
print(f"s.rindex: {s.rindex('wr')}")      #从右往左查找子串,如果找到那么返回子串中首字母在原字符串中的下标,如果没找到就在运行时抛出异常
print(f"s.count: {s.count('ll')}")        #统计子串在字符串中出现的次数
print(f"s.replace: {s.replace('hE', 'WWW')}")   #将原字符串中的所有指定子串('hE')替换为指定字符串('WWW')
print(f"s.startswitch: {s.startswith('hE')}")   #确定字符串开头是否是指定子串,是(True),否(False)
print(f"s.endswitch: {s.endswith('.txt')}\n")     #确定字符串结尾是否是指定子串,是(True),否(False)

#修改(返回新字符串,不改变原字符串)
print(f"s.strip: {s.strip()}")       #去除字符串两侧的空格并返回新的字符串,不改变原字符串(字符串也不能修改)
print(f"s.lstrip: {s.lstrip()}")     #去除字符串左侧的空格并返回新的字符串,不改变原字符串(字符串也不能修改)
print(f"s.rstrip: {s.rstrip()}\n")     #去除字符串右侧的空格并返回新的字符串,不改变原字符串(字符串也不能修改)

#分割与连接
s = "a ib ic id ie"    #原底层字符串被释放
print(f"s.split: {s.split(' i')}")      #从左侧开始按照指定的字符串将原字符串分割成列表
print(f"s.rsplit: {s.rsplit(' i')}")    #从右侧开始按照指定的字符串将原字符串分割成列表
print(f"s.join: {'-'.join(['a', 'b', 'c', 'd'])}")    #用字符串连接可迭代对象

要注意的是字符串也是可以使用切片的,只不过不能使用切片修改字符串,比如:

python 复制代码
a = "abcdef"
b = a[0:3]

print(b)
print([i for i in a[:]])

这些东西用多了就熟了,下面来看看str的底层实现:

python 复制代码
typedef struct {
    PyObject_HEAD           // 引用计数(ob_refcnt) + 类型指针(ob_type)
    Py_ssize_t length;      // 字符串长度(字符个数)
    Py_hash_t hash;         // 缓存的哈希值(-1 表示未计算)
    struct {
        unsigned int interned:2;   // 驻留状态
        unsigned int kind:2;        // 编码类型(1/2/4字节)
        unsigned int compact:1;    // 是否紧凑布局
        unsigned int ascii:1;      // 是否纯 ASCII
        // ... 其他标志位
    } state;
    void *data;             // 字符数据(柔性数组或指针)
} PyUnicodeObject;

显然str底层使用了柔性数组作为存储数据的容器,变量名可以对应到指向底层对象的指针,可以发现在python的类型几乎都是采取这种机制的,即:语言层变量名映射到底层指向类型对象的指针,所有类型都是拥有复杂的内置机制,只不过在str中类型的元数据多了一些,下面来看看元组(tuple)类型。

1.2.4元组(tuple)

  先来看看元组的初始化操作:

python 复制代码
t1 = (1, 2, 3)
t2 = 1, 2, 3
t3 = (1,)
t4 = tuple((1, 2, 3))

s = "12345"
t5 = tuple(s)

l = [1, 2, 3]
t6 = tuple(l)

print(t1)
print(t2)
print(t3)
print(t4)
print(t5)
print(t6)

最常使用的就是直接用()创建元组,不指定()直接写入元素也可以,但是可读性非常差,要注意的是在元组中元素个数为1时,必须加上逗号,否则创建的就是元素对象而非元组,比如:

python 复制代码
t1 = (1)
t2 = ("123")
t3 = ([1, 2, 3])

t4 = (1,)
t5 = ("123",)
t6 = ([1, 2, 3],)

print(type(t1))
print(type(t2))
print(type(t3))
print(type(t4))
print(type(t5))
print(type(t6))

元组本身属于不可变序列,但是在元组内部可以嵌套可变序列,在元组内部嵌套的可变序列就是可变的,可以认为一个元组只保证了一层元素的不变性,不保证内层序列自身的不变性,比如:

python 复制代码
t = (1, 2, [3, 4, 5, 4, 3], (6, [7, 8]), '9, 10')

print(t[2])
print(t[3][1][0])

t[0] = 11
t[2] = 90
t[2][0] = 50
t[3][0] = 80
t[3][1][1] = 20

print(t[2][::-1])
print(t[3][:])
print(t[:])

问题就是上方代码中错误的有哪些,把错误代码去掉后会打印出什么结果?让我们先画出内存视图:

在t中包含了我们目前学到到所有序列,即:列表(list),字符串(str)和元组(tuple),其中列表是可变序列,字符串元组都是不可变序列,但是字符串和元组是有些区别的,元组的内部可以嵌套新的序列,但是在字符串内部的就一定是字符,因此可以说字符串是严格的不可变的,而元组只保证本层不可变,嵌套层是否可变取决于嵌套层的序列类型,以上方的代码为例,首先是:

python 复制代码
t[0] = 11
t[2] = 90

t本身是元组,那么t就会保证本层不可变(可以认为和元组直接相连的\[\]就属于元组的保护范围,在底层就是让一次解引用找到的对象不可修改),因此这两行代码是错误的,然后是:

python 复制代码
t[2][0] = 50
t[3][0] = 80

首先是t20,t2找到了一个列表,因此t20访问到的就是列表中指向的元素,归列表管,因此就是可修改的,也可以说是超出了元组t的保护范围(解了两次引用),然后是t30,t3找到的是元组,因此t30访问到的就是t内层嵌套元组的元素,虽然超出了元组t的保护范围,但是又落入了元组t3的保护范围,因此t30不可修改,也就是说只有t20正确,最后是:

python 复制代码
t[3][1][1] = 20

同样的t3找到t内部嵌套的元组,但是t31找到的是t内层嵌套元组内层嵌套的列表,因此t311访问到的就是列表的元素,归列表管,超出了元组t3的保护访问,因此代码正确。然后来看看打印的情况,在此处我们可以复习一下切片的行为,首先看到:

python 复制代码
print(t[2])
print(t[3][1][0])

这两处非常简单,看图说话就可以了,会打印出3, 4, 5, 4, 3和7,我们使用C/C++程序员的视角来理解,一个t2可以变化为*(t+2),也就是说一个\[\]本质上就是一次指针解引用,对应到上图就是解一次引用就沿着蓝色线段往下走一层,而\[\]中的数字就是在一层中走的距离,比如t310就是这么走的:

对于我们C/C++程序员来说是基本操作,就不多说了,来看看实际结果:

来看看下半部分的print:

python 复制代码
t[2][0] = 50
t[3][1][1] = 20

print(t[2][::-1])
print(t[3][:])
print(t[:])

此时元组中的部分内容被修改了,有效修改语句只有上方代码中的那两行,来看看打印的情况:在第一处print中是t2::-1,首先是t2,访问到的是t元组中的列表,所以后面的切片就是对这个列表切的,切片操作是::-1,步长是负数,那么填充后的切片就是:4:-1 :-1,打印结果就是:3, 4, 5, 4, 50,然后是print(t3:),首先t3访问到的是元组中的元组,而切片操作:默认步长为1,填充后的结果是0:2:1,因此打印的结果为:(6, 7, 20),最后的print(t:)显然就是打印整个元组t了,结果是:(1, 2, 50, 4, 5, 4, 3, (6, 20, 8), '9, 10'),来看看实际情况:

结果正确,把内存视图画出来后就是看图说话了,没什么难的,下面来看看下一个序列类型。

1.2.5range对象

比起类型,其实range更像是一个方法,但是在python的官方定义中是把range定义为不可变序列类型的,同样的先来看看range的初始化:

python 复制代码
r1 = range(10)
r2 = range(0, 10)
r3 = range(0, 10, 2)
r4 = range(10, 0, -1)

print(type(r1))
print(f"r1: {[i for i in r1]}")
print(f"r2: {[i for i in r2]}")
print(f"r3: {[i for i in r3]}")
print(f"r4: {[i for i in r4]}")
print(r1)

显然range确实是一个类型,创建range的基本语法是range(start, stop, step),和切片的规则几乎是一模一样的,在此处就不详细说明了,唯一要补充的一点是range在python3中在底层就被优化成了惰性求值,也就是说一个range(10000000),不会直接在内存中一次开辟一大块空间存储10000000个值,不开辟任何存储值的内存,只开辟内存存储一个range对象的壳,在访问其中的元素时再按需创建,底层的range对象只会存储start,stop,setp三个int对象,不会存储任何范围中的值,值是在用户访问时计算出来的,比如:

python 复制代码
r = range(0, 10, 2) #0, 2, 4, 6, 8   没有10,左闭右开
print(r[3])

r3值的计算就是:0 + 3*2 = 6,也就是说range类型设计厉害的地方就在于其底层使用计算完全替代了内存的大范围存储,节省了大量的空间,对于计算出来的int值,如果是在小整数范围内的,那么使用完后会不会被销毁(本质上是引用了小整数池中预创建的int对象),大整数使用完后一般就会被GC回收掉,在需要大范围迭代时使用range就是最优雅的选择。

1.2.6复习

下面来复习一下我们目前学到的所有类型,首先是int类型:

python 复制代码
#int
i1 = 10
i2 = 10
i3 = i1
i3 += 100
i4 = 100000000
i5 = 100000000

print(f"i1 is i2: {i1 is i2}")
print(f"i1 == i2: {i1 == i2}")
print(f"i3 is i1: {i3 is i1}")
print(f"i3 == i1: {i3 == i1}")
print(f"i4 is i5: {i4 is i5}")
print(f"i4 == i5: {i4 == i5}")

i4 += 1
print(f"i4 is i5: {i4 is i5}")

print(f"i1: {i1}, i2: {i2}, i3: {i3}, i4: {i4}, i5: {i5}")

来分析一下上方代码的执行结果,首先是i1 is i2,不涉及修改,底层对于同值int直接引用同一个对象,结果是True;然后是i1 == i2,值判断,显然为True;然后是i3 is i1,注意到i3在后方自加了100,底层不会影响到i1而是给i3创建新int对象,因此结果是False;然后是i3 = = i1,显然结果是Flase;然后是i4 is i5,结果显然是True的,然后是i4 = = i5,结果为True,在后面i4自加了1,底层为i4创建出新的int对象,因此后面的i4 is i5是False,最终的结果就是:

True

True

False

False

True

True

False

10, 10, 110, 100000001, 100000000,

看看实际情况:

下面来看看float:

python 复制代码
#float
f1 = 0.3
f2 = 1/3
f3 = 5//3
f4 = 4%3
f5 = f1
f6 = 0.3
f7 = 4%3

print(f"f1: {f1}, f2: {f2}, f3: {f3}, f4: {f4}, f5: {f5}, f6: {f6}, f7: {f7}")
print(f5 is f1)
print(f6 is f1)
print(f7 is f4)

f5 += 0.9
print(f"f1: {f1}, f5: {f5}")
print(f1 is f5)

首先是值的判断,要补充的是在python中/执行的除法会保留小数部分,//执行的除法直接去除小数部分,%就是取余,在底层float就是使用C中的double存储值的,因此会出现精度丢失问题,首先f1的值是0.3,f2的值是0.3333...,f3是1, f4是1, f5是0.3,f6是0.3,f7是1,然后是底层对象的判断,我们推断解释器会进行和int类似的优化,让值相同的变量映射到底层同一个对象的指针上,因此最终结果为:

0.3, 0.33333..., 1, 1, 0.3, 0.3, 1

True

True

True

0.3, 1.2

False

来看看实际情况:

结果正确,这说明解释器确实是使用底层的引用计数让同值变量映射到同一个底层对象的,在值变化时就会创建出新的对象并改变对应变量的映射,下面来看看bool:

python 复制代码
#bool
b1 = bool([])
b2 = bool("")
b3 = bool(())
b4 = bool(range(0))
b5 = bool(0)
b6 = bool(1)
b7 = bool(None)


print(type(b1))
print(type(True+True-False))
print(False+False-True-True)
print(f"b1: {b1}, b2: {b2}, b3: {b3}, b4: {b4}, b5: {b5}, b6: {b6}, b7: {b7}")
print(b1 is b2 and b2 is b3 and b3 is b4 and b4 is b5 and b5 is b7)
print(b6 is b1)

在此处就要补充一个知识点了,在python中0, None和空序列都为False,因此在上方的b1, b2, b3, b4, b5, b7都为False,而在上文说明过bool底层就是int的1(True)和0(False),因此在进行运算时直接当成1和0就可以了,并且底层的int类型的1和0是全局的,也就是说所有True底层都是同一个int对象,同理所有的False在底层同样是同一个对象,因此得出最终的结果为:

class < bool >

class < int >

-2

False, False, False, False, False, True, False

True

False

来看看实际情况:

有关bool类型,还有一个值得注意的点,看到下方的代码:

python 复制代码
b1 = True
b6 = True

b1 += 5
b6 -= 1

print(b1)
print(b6)
print(type(b1))
print(type(b6))

bool类型的变量是可以自增或自减的,在操作后变为int类型,像b6不会因为自减1变为0后就变为False,而是直接变为int类型的0:

下面来复习复数complex:

python 复制代码
#complex
fs1 = 3+8j
fs2 = fs1
fs3 = fs1 + 10
fs4 = 9 + 0j
fs5 = fs2 + fs3
fs6 = fs1 / fs3
fs7 = fs1 * fs5   
fs8 = 3+8j

print(f"fs1: {fs1}, fs2: {fs2}, fs3: {fs3}, fs4: {fs4}, fs5: {fs5}, fs6: {fs6}, fs7: {fs7}, fs8: {fs8}")
print(type(fs4))
print(fs1 is fs2)
print(fs8 is fs1)
print(fs3.imag + fs3.imag)
print(type(fs2.real))
print(type(fs2.imag))
print(fs1.real + fs4.imag)

首先在python中是支持复数运算的,而且支持的非常好,这也是python中很多数学和物理库的基础,看到上方有关复数运算的代码,fs3 = fs1 + 10 = 3+8j + 10 = 13 + 8j,然后是fs5 = fs2 + fs3 = 3+8j + 13 + 8j = 16 + 16j,复数的加减法规则就是实部加减实部,虚部加减虚部,然后是fs6 = fs1 / fs3 = 3+8j13+8j=(3+8j)∗(13−8j)(13+8j)∗(13−8j)=(3+8j)∗(13−8j)132−82j2=3∗13−24j+8∗13j−8∗8∗j∗j132−82j2=103+80j233\frac{3+8j}{13 + 8j}=\frac{(3+8j)*(13-8j)}{(13 + 8j)*(13-8j)}=\frac{(3+8j)*(13-8j)}{13^2 - 8^2j^2}=\frac{3*13-24j+8*13j-8*8*j*j}{13^2 - 8^2j^2}=\frac{103+80j}{233}13+8j3+8j=(13+8j)∗(13−8j)(3+8j)∗(13−8j)=132−82j2(3+8j)∗(13−8j)=132−82j23∗13−24j+8∗13j−8∗8∗j∗j=233103+80j 复数中j2=−1j^2=-1j2=−1,高中知识,这里就不多说了,最后一个计算是fs7 = fs1 * fs5 = (3+8j)∗(16+16j)=3∗16+3∗16j+8∗16j+8∗16∗j∗j=−80+176j(3+8j)*(16 + 16j)=3*16+3*16j+8*16j+8*16*j*j=-80+176j(3+8j)∗(16+16j)=3∗16+3∗16j+8∗16j+8∗16∗j∗j=−80+176j 看到下方的类型判断:type(fs4),fs4中的虚部为0,从数学的角度看就是fs4没有虚部,那么就是实数,因此猜测type(fs4)会得出class < float>(在python中没有double类型,只有float,都是float的底层也是double),然后是fs1 is fs2,按照上方类型中的推断,我们有理由推测相同值的复数变量在底层会指向同一个对象,因此两处is应该都会打印出True,然后是fs3虚部相加,得出纯虚数16j,然后是判断虚部和实部的类型,在上文说明过复数在底层中的实部和虚部都是使用double实现的,因此有理由推测会打印出float类型,最后是实部加虚部,在数学中这是不允许的,因此多半会报错,最终的结果就是:

3+8j, 3+8j, 13 + 8j, 9, 16 + 16j, 0.442+0.343j, -80+176j, 3+8j

class < float >

True

Ture

16j

class < float >

class < float >

运行时报错

来看看实际情况:

有一部分错了,那么我们有理由推测在complex中只要是实部和虚部整体一起出现的,就是complex类型,和实部与虚部具体的值无关,当实部和虚部单独出现时,就是纯float类型,不会打印出j,在数学中的虚部也是不带j的,可以理解,那么就可以这样子:

python 复制代码
#complex
fs1 = 8+9j
f = fs1.real + fs1.imag

print(type(f))
print(f)

那么可以总结出来的结论是:在所有简单类型中只要变量的值相同,在底层就会引用同一个对象(在底层对对象的引用计数进行操作),当某个变量发生修改时,就会为其单独创建一个对象,有点类似于C++中的写时拷贝,以防万一,我们再看看complex中大数的情况:

python 复制代码
#complex
fs1 = 80000+9000j
fs2 = 80000+9000j
print(fs1 is fs2)
print(fs1 == fs2)
fs1 += 1

print(fs1 is fs2)

目前看来结论是正确的,后面遇到问题再修改便可,下面来复习一下list,str, tuple, range序列类型,先看到list:

python 复制代码
#list
l1 = [1, 2, 3, 4, 5]
l2 = [1, 2, 3, 4, 5]
l3 = l1

print(l2 is l1)
print(l3 is l1)
print(l1 == l2)
print(l1 == l3)
print(f"l1: {l1}, l2: {l2}, l3: {l3}")

l2[0] = 100
l3[0] = 99
print(l2 is l1)
print(l3 is l1)
print(f"l1: {l1}, l2: {l2}, l3: {l3}")

首先来复习一下表层变量引用底层对象的情况,其实笔者在上文也没讲,就是为了留在此处统一进行分析的,基于简单类型中的结论,我们有理由推测上半部分print中的l2 is l1和l3 is l1都是True,要说明的是list也可以使用==进行比较,列表对象中的元素完全相同是就会返回True,和C++中对= = 运算符重载的操作是类似的,因此l1 = = l2和l1 = = l3都会得出True,因此上半部分print的结果就是:

True

True

True

True

1, 2, 3, 4, 5\], \[1, 2, 3, 4, 5\], \[1, 2, 3, 4, 5

然后是下半部分的print,此处l2和l3修改了,那么根据简单类型的结论,我们有理由推测会发生类似于写实拷贝的行为,那么此时就会为l2和l3创建自己的对象,因此打印的结果就是:

False

False

1, 2, 3, 4, 5\], \[100, 2, 3, 4, 5\], \[99, 2, 3, 4, 5

来看看实际情况:

错了不少,看来序列类型和简单类型的底层行为还是有比较大的区别的,首先是l2,尽管其内容和l1完全相同,但是在底层依旧为其创建了新的列表对象,然后是l3,其直接就是l3=l1,在底层就引用了同一个列表对象,但是注意到l3在修改时没有发生写时拷贝行为,而是连带着把l1一起修改了,这就有点像C++中引用的行为,那么我们有理由推测:在列表中,如果是通过等号走了构造行为,那么就算已经存在相同值的对象,也不会直接进行引用,而是创建出新的对象,如果是通过等号走了"拷贝"另一个对象的行为,那么多个变量会指向同一个底层对象,并且在修改时不会发生写时拷贝,这仅仅只是根据少量现象信息得出的推测,肯定是存在问题的,我们还得观察更多的现象,先考虑下方情况:

python 复制代码
#list
l1 = [1]
l2 = [1]
l3 = l1

print(l2 is l1)
print(l3 is l1)
print(l1 == l2)
print(l1 == l3)
print(f"l1: {l1}, l2: {l2}, l3: {l3}")

l2[0] = 100
l3[0] = 99
print(l2 is l1)
print(l3 is l1)
print(f"l1: {l1}, l2: {l2}, l3: {l3}")

结果为:

这个结果说明列表元素的个数不会影响到我们上方的推测,再考虑下方情况:

python 复制代码
from copy import deepcopy
#list
l1 = [1, 2, 3, 4, [5, 6, 7, [8, 9, [10]]]]
l2 = l1.copy()
l3 = deepcopy(l1)
l4 = l1[:]

print(l2 is l1)
print(l2[4] is l1[4])
print(l2[4][3] is l1[4][3])

print(l3 is l1)

print(l4 is l1)
print(l4[4] is l1[4])
print(l4[4][3] is l1[4][3])

copy和deepcopy的情况我们在上方就分析过了,copy会拷贝壳和第一层,更深层的对象直接引用原变量的,因此前三个print是False, Ture, True;deepcopy会递归拷贝整个对象,因此第四个print是False,然后是使用切片进行拷贝操作,这个我们无法确定情况,来看看结果:

从结果来看和copy的行为是一样的,也就是拷贝壳和第一层对象,深层直接引用原变量的,因此对于list的拷贝/引用行为可以得出第一次结论:

list拷贝/引用行为结论
1.变量2 = 变量1,直接让变量2引用变量1底层的对象,并且此时变量2的修改会影响到变量1,类似于C++中的引用行为
2.变量2 = 变量1.copy()或变量2 = 变量1:,变量2拷贝变量1的壳和第一层对象,深层对象直接引用变量1的,在变量2修改深层对象时会影响到变量1
3.变量2 = deepcopy(变量1),变量2递归拷贝变量1,之后变量2和变量1没有然后联系

然后来复习一下列表的常用内置函数和切片操作:

python 复制代码
#list
l1 = [1, 2, 3, 1, 2, 3, 4, [5, 6, 7, [8, 9, [10]]]]

l1.append(11)
l1.insert(3, [-1, -2, -3, [-5, -6]])
l1.pop(2)
l1[2].pop()
l1.extend([99, [100, 101, [102]]])
#l1.extend(10)
l1+=[43, 42, 45]
#l1+=100
l1.extend([i for i in range(1, 4, 2)])
l1[0] = 100
l1.reverse()
l1[8][3].reverse()
l1.remove(4)
l1[5].remove(101)

print(l1)

让我们一句句分析出最终的结果,首先append是在尾部插入一个元素,因此插入后得出:

1, 2, 3, 1, 2, 3, 4, \[5, 6, 7, \[8, 9, \[10\]\]\], 11

然后是insert,在指定下标出插入指定元素,插入后新元素的下标成为指定的下标,得出:

1, 2, 3, \[-1, -2, -3, \[-5, -6\]\], 1, 2, 3, 4, \[5, 6, 7, \[8, 9, \[10\]\]\], 11

然后是pop,默认是弹出尾部元素,但是可以指定要弹出元素的下标(指定后时间复杂度变为O(n)),因此得出:

1, 2, \[-1, -2, -3, \[-5, -6\]\], 1, 2, 3, 4, \[5, 6, 7, \[8, 9, \[10\]\]\], 11

然后又是一个pop,此时l12就是-1, -2, -3, \[-5, -6],因此就是弹出该列表的尾部元素,得出:

1, 2, \[-1, -2, -3\], 1, 2, 3, 4, \[5, 6, 7, \[8, 9, \[10\]\]\], 11

然后是extend,把可迭代对象中的每一个元素遍历插入列表尾部,得出:

1, 2, \[-1, -2, -3\], 1, 2, 3, 4, \[5, 6, 7, \[8, 9, \[10\]\]\], 11, 99, \[100, 101, \[102\]\]

然后又是一个extend,在该extend中指定的不是可迭代对象,因此会运行时报错,笔者注释掉了,然后是+=,底层调用entend,得出:

1, 2, \[-1, -2, -3\], 1, 2, 3, 4, \[5, 6, 7, \[8, 9, \[10\]\]\], 11, 99, \[100, 101, \[102\]\], 43, 42, 45

然后又是一个+=,不过注意到此时+=的不是可迭代对象,因此会运行时报错,不会转而调用append,笔者注释掉了,然后又是一个entend,传入了一个由列表推导式生成的列表,使用range生成,生成的列表为:1, 3,因此得出:

1, 2, \[-1, -2, -3\], 1, 2, 3, 4, \[5, 6, 7, \[8, 9, \[10\]\]\], 11, 99, \[100, 101, \[102\]\], 43, 42, 45, 1, 3

然后是l10 = 100,得出:

100, 2, \[-1, -2, -3\], 1, 2, 3, 4, \[5, 6, 7, \[8, 9, \[10\]\]\], 11, 99, \[100, 101, \[102\]\], 43, 42, 45, 1, 3

然后是reverse列表逆序,得出:

3, 1, 45, 42, 43, \[100, 101, \[102\]\]\], 99, 11, \[5, 6, 7, \[8, 9, \[10\]\]\], 4, 3, 2, 1, \[-1, -2, -3\], 2, 100

然后又是一个reverse,首先l18访问到列表5, 6, 7, \[8, 9, \[10]],因此l183访问到列表8, 9, \[10],对该列表逆序,得出新的嵌套列表为:\[10, 9, 8],得出:

3, 1, 45, 42, 43, \[100, 101, \[102\]\]\], 99, 11, \[5, 6, 7, \[\[10\], 9, 8\], 4, 3, 2, 1, \[-1, -2, -3\], 2, 100

然后是remove,删除列表中第一个与指定元素相同的元素,因此得出:

3, 1, 45, 42, 43, \[100, 101, \[102\]\]\], 99, 11, \[5, 6, 7, \[\[10\], 9, 8\], 3, 2, 1, \[-1, -2, -3\], 2, 100

然后又是一个remove,首先l15访问到列表100, 101, \[102]],删除的就是该列表中的元素,得出新嵌套列表为 100, \[102]],因此最终打印的结果为:

3, 1, 45, 42, 43, \[100, \[102\]\]\], 99, 11, \[5, 6, 7, \[\[10\], 9, 8\], 3, 2, 1, \[-1, -2, -3\], 2, 100

来看看实际情况:

结果正确,下面以该列表复习一下切片操作:

python 复制代码
[3, 1, 45, 42, 43, [100, [102]], 99, 11, [5, 6, 7, [[10], 9, 8]], 3, 2, 1, [-1, -2, -3], 2, 100]
print(l1[2:9:3])
print(l1[8:3:-1])
print(l1[5][::-1])
print(l1[:5:-1])
print(l1[:10:1])

print(l1[::5])
print(l1[::-4])
print(l1[8][::-3])
print(l1[9:2:3])
print(l1[1:4:-1])

print(l1[8][3:1])
print(l1[-5:-1:2])
print(l1[-1:-8:-3])
print(l1[-4:-7:2])
print(l1[:-3:-1])
print(l1[1:-4])
print(l1[-5::1])

l1[2:6] = [1, 2, 3, 4, 5, [1, 2, 3], 4, 3, 2]
l1[1:3] = [10]
l1[5:3:-1] = [80, 32, 31]
l1[6:3:-1] = [3, 2, 4]
l1[4] = 90
l1[1:5:3] = [3, 5]
l1[3:8:2] = [3, 2, 9, 100]
l1[3:9:3] = [0, 8]
l1[5:1:-2] = [9, 98]
l1[1:1] = 30
l1[2:6] = 98

print(l1)
print(l1.count(3))
print(len(l1))

3, 1, 45, 42, 43, \[100, \[102\]\]\], 99, 11, \[5, 6, 7, \[\[10\], 9, 8\]\], 3, 2, 1, \[-1, -2, -3

首先是print(l12:9:3),从2开始到9结束,步长为3,有效范围是[2, 9),访问到的下标为2, 5, 8得出:

45, \[100, \[102\]\], \[5, 6, 7, \[\[10\], 9, 8\]\]

然后是print(l18:3:-1),从8开始到3结束,步长为-1,有效范围是[8, 3),访问到的下标为8, 7, 6, 5, 4,得出:

\[5, 6, 7, \[\[10\], 9, 8\]\], 11, 99, \[100, \[102\]\], 43

然后是print(l15::-1),首先l15访问到100, \[102],没有指定start和end,步长为-1,因此默认指定start = len(l15) -1 = 1,stop为-1,有效范围是[1, -1),访问到的下标为1, 0,得出:

\[102\], 100

然后是print(l1:5:-1),没有指定start,而步长是负数,因此start默认为len(l1)-1 = 15-1 = 14,有效范围是[14, 5),访问到下标14, 13, 12, 11, 10, 9, 8, 7, 6得出:

100, 2, \[-1, -2, -3\], 1, 2, 3, \[5, 6, 7, \[\[10\], 9, 8\]\], 11, 99

然后是print(l1:10:1),没有指定start,步长为正数,因此start默认为0,有效范围是[0, 10),访问到下标0, 1, 2, 3, 4, 5, 6, 7, 8, 9得出:

3, 1, 45, 42, 43, \[100, \[102\]\], 99, 11, \[5, 6, 7, \[\[10\], 9, 8\]\], 3

前五个print得出的结果就是:

45, \[100, \[102\]\], \[5, 6, 7, \[\[10\], 9, 8\]\]

\[5, 6, 7, \[\[10\], 9, 8\]\], 11, 99, \[100, \[102\]\], 43

\[102\], 100

100, 2, \[-1, -2, -3\], 1, 2, 3, \[5, 6, 7, \[\[10\], 9, 8\]\], 11, 99

3, 1, 45, 42, 43, \[100, \[102\]\], 99, 11, \[5, 6, 7, \[\[10\], 9, 8\]\], 3

看看实际情况:

结果正确,下面来分析第二组print,首先是print(l1::5),没有指定start和stop,步长为正数,因此start默认为0, stop默认为len(l1) = 15,有效范围是[0, 15),由于步长为5,因此访问到的下标是:0, 5, 10得出:

3, \[100, \[102\]\], 2

然后是print(l1::-4),没有指定start和stop,步长为负数,因此start默认为len(l1)-1 = 14,stop默认为-1,有效范围是[14, -1),由于步长是-4,因此访问到的下标为:14, 10, 6, 2得出:

100, 2, 99, 45

然后是print(l18::-3),首先l18访问到嵌套列表5, 6, 7, \[\[10, 9, 8]],没有指定start和stop,步长为-3, 因此start默认为len(l18)-1 = 3, stop默认为-1,有效范围是[3, -1),访问到元素3, 0得出:

\[\[10\], 9, 8\], 5

然后是print(l19:2:3),步长为正数,但是start > stop,因此得出空集:

然后是print(l11:4:-1),步长为负数,但是start < stop,因此得出空集:

该组print最终结果就是:

3, \[100, \[102\]\], 2

100, 2, 99, 45

\[\[10\], 9, 8\], 5

看看实际情况:

结果正确,下面来看下一组print,首先是print(l183:1),首先l18访问到嵌套列表5, 6, 7, \[\[10, 9, 8]],没有指定步长,那么默认为1,此时由于start > stop,因此会得出空集:

然后是print(l1-5: -1:2),我们先利用公式"正数下标 = n + 负数下标"将负数start和stop转为正数,得出start = len(l1)-5 = 15-5 = 10,stop = 15 - 1 = 14,因此有效范围是[10, 14),由于步长为2,所以访问到的下标为:10, 12得出:

2, \[-1, -2, -3\]

然后是print(l1-1:-8:-3),同样的先将start和stop转为正数,start = 15-1 = 14, stop = 15-8 = 7,有效范围是[14, 7),由于步长是-3,因此访问到下标14, 11, 8得出:

100, 1, \[5, 6, 7, \[\[10\], 9, 8\]\]

然后是print(l1-4:-7:2),负转正start = 15 - 4 = 11,stop = 15 - 7 = 8,有效范围是[11, 8),步长为正数,此时由于start > stop,因此得出空集:

然后是print(l1:-3:-1),没有指定start,步长为负数,那么start默认为len(l1)-1 = 14,然后将stop转为正数stop = 15 - 3 = 12,有效范围是[14, 12),步长为-1,因此访问到的下标是14, 13得出:

100, 2

然后是print(l11:-4),先将stop转正,stop = 15 - 4 = 11,有效范围是[1, 11),步长默认是1,因此访问到的下标是1, 2, 3, 4, 5, 6, 7, 8, 9, 10得出:

1, 45, 42, 43, \[100, \[102\]\], 99, 11, \[5, 6, 7, \[\[10\], 9, 8\]\], 3, 2

然后是print(l1-5::1),没有指定stop,步长为正数,因此stop默认为len(l1) = 15,然后将start转正start = 15-5 = 10,得出有效范围是[10, 15),访问到下标10, 11, 12, 13, 14得出:

2, 1, \[-1, -2, -3\], 2, 100

该组print最终结果为:

2, \[-1, -2, -3\]

100, 1, \[5, 6, 7, \[\[10\], 9, 8\]\]

100, 2

1, 45, 42, 43, \[100, \[102\]\], 99, 11, \[5, 6, 7, \[\[10\], 9, 8\]\], 3, 2

2, 1, \[-1, -2, -3\], 2, 100

来看看实际情况:

结果正确,下面来看看后面的切片修改列表,在此处要补充一个语法,考虑下方代码:

python 复制代码
l = [1, 2, 3, 4, 5]
l[::2] = [10, 20, 30]

此时切片有效范围是[0, 5),步长为2,访问到的下标是0, 2, 4那么此时的切片赋值操作就会将下标0赋值为10,下标2赋值为20,下标4赋值为30,也就是一一对应的进行赋值,如果此时指定赋值的列表中的元素与能够访问的下标个数不同的话,就会在运行时报错,比如:

python 复制代码
l = [1, 2, 3, 4, 5]
l[::2] = [1, 2]

还要说明的是只有在步长不是1时才会触发上述的机制,如果步长是单步的1的话,那么就会批量进行替换,比如:

python 复制代码
l = [1, 2, 3, 4, 5]
l[1:3] = [3, 2, 4, 5, 6]
print(l)

此时切片有效范围就是[1, 3),步长为1,那么在该下标范围内的2, 3就会被替换为3, 2, 4, 5, 6,即:

简单来说就是步长不是1就必须让指定替换列表元素的个数等于被替换元素的个数,但是在步长是1时没有这种限制,要注意的是如果步长是-1的话,那么同样需要满足替换列表中元素的个数等于被替换元素的个数,即:

python 复制代码
l = [1, 2, 3, 4]
l[3:1:-1] = [1, 2, 3, 4, 5]

总的来说就是步长为1时可以批量替换,不为1时必须对应着进行替换,下面开始分析上方的测试用例:

python 复制代码
#l1 = [3, 1, 45, 42, 43, [100, [102]], 99, 11, [5, 6, 7, [[10], 9, 8]], 3, 2, 1, [-1, -2, -3], 2, 100]

l1[2:6] = [1, 2, 3, 4, 5, [1, 2, 3], 4, 3, 2]
l1[1:3] = [10]
#l1[5:3:-1] = [80, 32, 31]
l1[6:3:-1] = [3, 2, 4]
l1[4] = 90
l1[5] = [1, 2, 3]
l1[1:1:1] = [4, 5, 3]
l1[5:1] = [909]
l1[100:300] = [1, 2, 3]

#l1[3:9:-1] = [989]
#l1[3:3:1] = 99
l1[16][2:90] = [1, 3, 4]
l1[18:-5] = [45, [3, 2]]
l1[1:5:2] = [[1, 2], [3, 2, 1]]
#l1[9:3:-3] = [1, 2, 3]
l1[-5:-1:2] = [3, 2]
l1[30:50] = [3, 4, 5]
#l1[100] = 90
l1[22:900] = [1, 2, 4, 3]

首先是l12:6 = 1, 2, 3, 4, 5, \[1, 2, 3, 4, 3, 2],步长为1,替换列表没有元素个数限制,那么原列表中的[2, 6)下标范围内的元素被替换为指定列表中的元素,得出:

3, 1, 1, 2, 3, 4, 5, \[1, 2, 3\], 4, 3, 2, 99, 11, \[5, 6, 7, \[\[10\], 9, 8\]\], 3, 2, 1, \[-1, -2, -3\], 2, 100

然后是l11:3 = 10,下标范围[1, 3)内的元素被替换,得出:

3, 10, 2, 3, 4, 5, \[1, 2, 3\], 4, 3, 2, 99, 11, \[5, 6, 7, \[\[10\], 9, 8\]\], 3, 2, 1, \[-1, -2, -3\], 2, 100

然后是l15:3:-1 = 80, 32, 31,此时步长不是1,要满足一一对应的替换,替换范围是5, 3),步长为-1,有效下标是5, 4两个元素,但是替换列表中指定了三个元素,因此会报错,笔者注释掉了,然后是l1\[6:3:-1 = 3, 2, 4,此时步长依旧不是1,需要一一对应的替换,有效范围是[6, 3),可访问下标是6, 5, 4三个元素,和替换列表中的元素个数相同,因此下标处6元素被替换成3,5处被替换为2,4处被替换为4,得出:

3, 10, 2, 3, 4, 2, 3, 4, 3, 2, 99, 11, \[5, 6, 7, \[\[10\], 9, 8\]\], 3, 2, 1, \[-1, -2, -3\], 2, 100

然后是l14 = 90,直接就是修改,得出:

3, 10, 2, 3, 90, 2, 3, 4, 3, 2, 99, 11, \[5, 6, 7, \[\[10\], 9, 8\]\], 3, 2, 1, \[-1, -2, -3\], 2, 100

然后是l15 = 1, 2, 3,此时要注意的是没有使用切片操作进行替换时,会保留最外层的\[\],使用切片进行替换时,会去除最外层的\[\],本质上是因为没有使用切片时是单值替换,使用了切片后是值的迭代替换,因此得出:

3, 10, 2, 3, 90, \[1, 2, 3\], 3, 4, 3, 2, 99, 11, \[5, 6, 7, \[\[10\], 9, 8\]\], 3, 2, 1, \[-1, -2, -3\], 2, 100

然后是l11:1:1 = 4, 5, 3,此时范围有效范围是[1, 1),显然会得出空集,此时神奇的是会从start下标开始往后添加元素,也就是让4成为下标1元素,5成为下标2元素,3成为下标3元素(笔者觉得这个设计怪怪的),因此得出:

3, 4, 5, 3, 10, 2, 3, 90, \[1, 2, 3\], 3, 4, 3, 2, 99, 11, \[5, 6, 7, \[\[10\], 9, 8\]\], 3, 2, 1, \[-1, -2, -3\], 2, 100

然后是l15:1 = 909,此时默认步长为1,但是start > stop,因此是空集,从start下标出开始添加元素,因此得出:

3, 4, 5, 3, 10, 909, 2, 3, 90, \[1, 2, 3\], 3, 4, 3, 2, 99, 11, \[5, 6, 7, \[\[10\], 9, 8\]\], 3, 2, 1, \[-1, -2, -3\], 2, 100

然后是l1100:300 = 1, 2, 3,此时步长默认是1,但是start显然越界了,会打印出空集,不过不会报错,而从列表尾部追加元素,因此得出:

3, 4, 5, 3, 10, 909, 2, 3, 90, \[1, 2, 3\], 3, 4, 3, 2, 99, 11, \[5, 6, 7, \[\[10\], 9, 8\]\], 3, 2, 1, \[-1, -2, -3\], 2, 100, 1, 2, 3

最终第一组切片操作替换结束后得出的就是:

3, 4, 5, 3, 10, 909, 2, 3, 90, \[1, 2, 3\], 3, 4, 3, 2, 99, 11, \[5, 6, 7, \[\[10\], 9, 8\]\], 3, 2, 1, \[-1, -2, -3\], 2, 100, 1, 2, 3

来看看实际情况:

结果正确,总结一下可以得出:

总结
1.当切片的步长不是1时,必须让替换列表中的元素一一对应上切片范围内的元素,否则就会运行时报错
2.当切片的步长为1时,可以进行不一一对应的替换,此时如果切出空集,那么就会从start开始往后追加元素,如果start超出最大范围,那么就在列表末尾追加元素,如果stop超出范围,那么就会从start开始替换掉后面的所有元素
3.当没有使用切片时是列表中的单值修改,会保留替换列表最外层的\[\],此时如果指定的下标超出了有效范围,那么就会在运行时报错,当使用切片替换时列表中元素是迭代批量修改,不会保留替换列表最外层的\[\]

然后来看看下一组切片的替换,首先是l13:9:-1 = 989,此时步长是-1,start < stop,因此得出空集,没有满足一一匹配,因此会运行时报错,笔者注释掉了。然后是l13:3:1 = 99,要补充的是在使用切片进行替换时,替换列表必须是可迭代的,也就是说至少要有一层\[\],否则就会运行时报错,因此该条代码会报错,笔者注释掉了。然后是l1162:90 = 1, 3, 4,首先l116访问到嵌套列表5, 6, 7, \[\[10, 9, 8]],然后注意到范围是[2, 90),stop显然越界了,那么默认就会变为len(l1),因此会替换掉嵌套列表中从下标2开始往后的所有元素,得出:

3, 4, 5, 3, 10, 909, 2, 3, 90, \[1, 2, 3\], 3, 4, 3, 2, 99, 11, \[5, 6, 1, 3, 4\], 3, 2, 1, \[-1, -2, -3\], 2, 100, 1, 2, 3

然后是l118:-5 = 45, \[3, 2],此时步长默认为1,注意到stop为负数,先将其转正stop = len(l1) - 5 = 26 - 5 = 21,得出替换范围是[18, 21),因此得出:

3, 4, 5, 3, 10, 909, 2, 3, 90, \[1, 2, 3\], 3, 4, 3, 2, 99, 11, \[5, 6, 1, 3, 4\], 3, 45, \[3, 2\], 2, 100, 1, 2, 3

然后是l11:5:2 = \[1, 2, 3, 2, 1],此时步长不是1,需要替换列表需要满足一一对应,有效范围是[1, 5),步长为2,访问到的下标是1, 3满足一一对应,因此得出:

3, \[1, 2\], 5, \[3, 2, 1\], 10, 909, 2, 3, 90, \[1, 2, 3\], 3, 4, 3, 2, 99, 11, \[5, 6, 1, 3, 4\], 3, 45, \[3, 2\], 2, 100, 1, 2, 3

然后是l19:3:-3 = 1, 2, 3,步长不是1,需要替换列表满足一一对应,此时有效范围是9, 3),步长为-3,访问到的下标是9, 6,替换列表元素个数3,不满足一一对应,因此会运行时报错,笔者注释掉了。然后是l1\[-5: -1:2 = 3, 2,此时步长不是1,需要满足替换列表一一对应,先将start和stop替换为正数,得出start = len(l1) - 5 = 25 - 5 = 20,stop = 25 - 1 = 24,有效范围是[20, 24),步长为2,访问到的下标是20, 22满足一一对应,因此得出:

3, \[1, 2\], 5, \[3, 2, 1\], 10, 909, 2, 3, 90, \[1, 2, 3\], 3, 4, 3, 2, 99, 11, \[5, 6, 1, 3, 4\], 3, 45, \[3, 2\], 3, 100, 2, 2, 3

然后是l130:50 = 3, 4, 5,此时start超出了start的范围,步长为1,因此直接在尾部追加,得出:

3, \[1, 2\], 5, \[3, 2, 1\], 10, 909, 2, 3, 90, \[1, 2, 3\], 3, 4, 3, 2, 99, 11, \[5, 6, 1, 3, 4\], 3, 45, \[3, 2\], 3, 100, 2, 2, 3, 3, 4, 5

然后是l1100 = 90,直接使用下标访问在超出有效范围时会运行时报错,笔者注释掉了,最后是l122:900 = 1, 2, 4, 3,步长为1,stop超出范围,相当于变为len(l1),有效范围是[22, 28),此时由于步长为1,不需要满足一一对应的替换,因此得出:

3, \[1, 2\], 5, \[3, 2, 1\], 10, 909, 2, 3, 90, \[1, 2, 3\], 3, 4, 3, 2, 99, 11, \[5, 6, 1, 3, 4\], 3, 45, \[3, 2\], 3, 100, 1, 2, 4, 3

来看看实际情况:

结果正确,总体来看切片操作还是比较复杂的,但是按照笔者上方总结的规则来判断在大部分情况下都是没什么问题的,然后复习下一个序列:字符串(str)。

  字符串属于不可修改的序列,而且是严格的不可修改,不存在字符串中嵌套可修改序列的情况,同样的我们先复习一下表层变量名和底层对象之间的关系,看到下方代码:

python 复制代码
#str
s1 = "hello wrold"
s2 = "hello wrold"
s3 = s1

print(s1 is s2)
print(s3 is s2)

s3 = "123"
print(s1)
print(s2)
print(s3)

结果为:

显然同值变量在底层引用同一个对象,但是在变量修改会发生类似于写实拷贝的行为,和简单类型的行为似乎是一样的,我们考虑字符串长度的影响:

python 复制代码
#str
#str
s1 = ' '
s2 = " "

s3 = "jsfjkkkkkkweioj;jiojeeeeeelskjfasfioueljsdf  sfjoiefajfdsjfohglsdkjfoisdfjljoa;ef lksjfopijeflkajdfeoijw asdfiojapojsdf1098098fadjfadfopihqwp"
s4 = 'jsfjkkkkkkweioj;jiojeeeeeelskjfasfioueljsdf  sfjoiefajfdsjfohglsdkjfoisdfjljoa;ef lksjfopijeflkajdfeoijw asdfiojapojsdf1098098fadjfadfopihqwp'

print(s1 is s2)
print(s3 is s4)

s2 = "1234"
s4 = "4321"
print(s1)
print(s2)
print(s3)
print(s4)

结果如下:

显然字符串长度和单双引号不影响上方的结论,因此我们有理由推测序列和简单类型的行为类似,就是在值相同时让所有变量引用底层同一个对象,在某一个变量修改时就会触发类似于写时拷贝的行为,不影响其它相同值的变量,下面来复习一下字符串序列常用的方法:

python 复制代码
#str
s1 = " t he234Llo wRold"

print(s1.title())
print(s1.capitalize())
print(s1.swapcase())
print(s1.upper())
print(s1.lower())

print(s1.find('o'))
print(s1.find('U'))
print(s1.find("d", 2, 5))
print(s1.rfind('t'))
print(s1.rfind('e', 1, 8))
print(s1.rfind("p", 4))
#print(s1.index('E'))
#print(s1.index("0", 1, 3))
print(s1.rindex("L"))
print(s1.rindex('t', 0, 3))

print(s1.replace('o', '123', 2))
print(s1.count('wR'))
print(s1.startswith(" t"))
print(s1.endswith("k"))
print(s1.isalnum())
print(s1.isalpha())

第一组print中都是大小转换的方法,首先是print(s1.title()),title方法是将每个单词的首字母转大写,得出:

" T He234Llo WRold"

要注意的是所有方法都不改变s1本身,操作的都是副本(因为字符串严格的不可修改),然后是print(s1.capitalize()),capitalize是将首字母转大写,其余字母转小写,得出:

" T he234llo wRold"

然后是print(s1.swapcase()),swapcase是大小写互换,得出:

" T HE234lLO WrOLD"

然后是print(s1.upper()),所有字母转大写,得出:

" T HE234LLO WROLD"

然后是print(s1.lower()),所有字母转小写,得出:

" t he234llo wrold"

来看看实际情况:

第一个错了,title应该不是根据空格为字母分隔符的,而是所有非字母都是分隔符,并且在转换时的行为应该是字母首字母大写,其余字母小写,来测试一下,考虑下方代码:

python 复制代码
s1 = "ab;cDE fgH3i'jK$lm9nOP QRST~uVw*xyZ"
s2 = "12345"
print(s1.title())
print(s2.title())

如果猜测正确,那么得出的答案就是:

"Ab;Cde Fgh3I'Jk$Lm9Nop Qrst~Uvw*Xyz"

12345

看看实际情况:

结果正确,因此最终得出title的行为是:将所有非字母字符都视为单词的分隔符,然后让每个单词的首字母变为大写,其它字母全部变为小写,如果字符串中不存在字母,那么就不做任何处理,下面考虑第二组print,首先是print(s1.find('o')),find是从左往右找出指定下标范围内(左闭右开)子串首字母在原字符串中的下标,如果不指定就默认范围是整个字符串,如果没找到就返回-1,rfind就是反过来找,index和find的查找行为类似只不过没找到时会在运行时抛异常,而rindex就是反向查找,笔者将会抛异常的index和rindex注释掉了,得出的结果就是:

10

-1

-1

1

4

-1

8

1

看看实际情况:

结果正确,下面来使用上方的子串查找方法简单的实现一个常用的网址解析功能函数:函数实现如下:

python 复制代码
def SepHttps(s, l):
    i1 = s.find(":")
    l.append(s[0:i1])
    i1 += 3
    i2 = 0
    while(1):
        i2 = s.find("/", i1)
        if(i2 == -1): break
        l.append(s[i1:i2])
        i1 = i2+1
    i1 += 1
    i2 = s.find('?', i1)
    if(i2 == -1): return
    l.append(s[i1:i2])
    l.append(s[i2+1:])



s = "https://www.kimi.com/chat/19f09816-a2a2-8e85-8000-09882273efa8?chat_enter_method=home"

l = []
SepHttps(s, l)
print(l)

函数和语句具体的语法笔者在后续章节中会详细讲解,在这里先看一下,下面来看看最后一组print,首先是print(s1.replace('o', '123', 2)),replace的行为有点复杂,看到下方的测试代码:

python 复制代码
s = '00000'

#print(s.replace('0'))
print(s.replace("1", '2'))
print(s.replace('0', '1'))
print(s.replace('0', '1', 0))
print(s.replace('0', '1', 2))
print(s.replace('00', '1', 3))
print(s.replace('000', '1', 100))

首先replace的第一个参数指定的是原字符串中要被替换的子串,第二个参数是指定要替换进入的字符串,第三个参数是指定替换子串的个数,如果不指定的话那么就默认替换掉字符串中所有指定的子串,要注意的是第一第二个参数必须指定,否则会报错,比如上方代码中的第一个print就会报错,笔者注释掉了,最终的结果是:

容易发现如果指定的子串不在原字符串中,那么就不会做任何处理,如果第三个参数指定为0,那么同样不会替换,如果指定的个数超出子串个数,那么就替换掉所有子串,现在看回到:

python 复制代码
s1 = " t he234Llo wRold"
print(s1.replace('o', '123', 2))
print(s1.count('wR'))
print(s1.startswith(" t"))
print(s1.endswith("k"))
print(s1.isalnum())
print(s1.isalpha())

replace得出的结果就是:

" t he234Ll123 wR123ld"

然后是count,求子串的个数,结果显然是:

1

然后是startswitch,查看原字符串中开头处的子串是否是指定字符串,结果是:

True

然后是endswitch,查看原字符串结尾处的子串是否是指定字符串,结果是:

False

然后是isalnum,查看字符串中是不是全部都是数字,结果是:

False

最后是isalpha,查看字符串中是不是全部都是字母,结果是:

False

来看看实际情况:

结果正确,字符串常用的方法就是这些了,总共有40多个方法,笔者自然是不可能全部覆盖到的,下面来复习下一个序列,即元组(tuple),同样的先测试表层变量和底层对象的关系,考虑下方代码:

python 复制代码
t1 = (1, 2, 3)
t2 = (1, 2, 3)
t3 = t1
print(t2 is t1)
print(t3 is t1)

显然在值相同时表层变量会引用同一个底层对象,再看看数据量是否会造成影响:

python 复制代码
t1 = (1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 10, 11, 12, 13, 14, 15)
t2 = (1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 10, 11, 12, 13, 14, 15)

t3 = ()
t4 = ()

print(t1 is t2)
print(t3 is t4)

显然长度不会影响该行为,不过要注意的是元组并不是严格的不可变序列,下面来看看在其内部嵌套列表的情况:

python 复制代码
t1 = (0, [1, 2, 3, 4, 5, [6, 7]])
t2 = (0, [1, 2, 3, 4, 5, [6, 7]])
t3 = t1

print(t2 is t1)
print(t3 is t1)

表现出了不同的行为,此时的行为和列表非常相似,即值相等在底层不指向同一个对象,而是创建新对象,直接让一个变量等于另一个变量在底层就会引用同一个对象,那么我们有理由推测可以通过t3修改t1:

python 复制代码
t1 = (0, [1, 2, 3, 4, 5, [6, 7]])
t2 = (0, [1, 2, 3, 4, 5, [6, 7]])
t3 = t1

t3[1][0] = 1000
print(t1)

结果如我们所料,也就是说在元组中嵌套可变序列时就会表现出可变序列的变量和底层关系行为,下面来看看嵌套不可变序列的情况:

python 复制代码
t1 = (0, "1, 2", (3, 4))
t2 = (0, "1, 2", (3, 4))
t3 = t1

print(t2 is t1)
print(t3 is t1)

最后来测试一下整体修改时的情况:

python 复制代码
t1 = (0, "1, 2", (3, 4))
t2 = (0, "1, 2", (3, 4))
t3 = t1

t3 = (1, 2)

print(t2 is t1)
print(t3 is t1)

发生了类似于写时拷贝的行为,由此我们可以得出初步结论:当元组中没有嵌套可变序列时,元组变量和底层对象的行为和简单类型类似,变量值相同就引用同一个底层对象,某个变量整体修改时就发生类似于写时拷贝的行为,当元组中嵌套了可变序列时,那么变量和对象的行为类似于可变序列,变量值相同也会在底层创建新对象,直接让变量等于另一个变量就发生类似于C++中的引用行为,看到下方代码:

python 复制代码
t1 = (0, [1, 2, 3, 4, 5, [6, 7, 8, [9, 10]]])
t2 = t1[:]

print(t2 is t1)
print(t2[1] is t1[1])
print(t2[1][5] is t1[1][5])
print(t2[1][5][3] is t1[1][5][3])

使用了切片进行拷贝,要注意的是元组没有提供copy方法,此时我们推测只会拷贝壳和第一层,因此前两个print的结果是True,后面两个print的结果是False,来看看实际情况:

显然此时没有发生拷贝行为,直接就让t2引用了t1的底层对象,那么应该就能够使用t2修改t1:

python 复制代码
t1 = (0, [1, 2, 3, 4, 5, [6, 7, 8, [9, 10]]])
t2 = t1[:]

t2[1][0] = 10000
t2[1][5][1] = 99
t2[1][5][3][0] = 88

print(t2 is t1)
print(t1)

下面来看看不存在可变序列的情况:

python 复制代码
t1 = (1, 2, 3, (4, 5), "6, 7")
t2 = t1[:]

print(t2 is t1)

由此得出最终的结论:当元组中没有嵌套可变序列时,元组变量和底层对象的行为和简单类型类似,变量值相同就引用同一个底层对象,某个变量整体修改时就发生类似于写时拷贝的行为,当元组中嵌套了可变序列时,那么变量和对象的行为类似于可变序列,变量值相同也会在底层创建新对象,直接让变量等于另一个变量就发生类似于C++中的引用行为,无论元组中有没有嵌套可变序列,使用切片进行拷贝时都会直接让变量完整的引用另一个变量的底层对象,并且此时可以通过其中一个变量修改元组内部元素来影响另一个变量

  至此本节结束,在下一节中笔者会分析剩下的类型,从C/C++视角学习python确实是个不错的选择,希望本节内容能够让读者有所收获。