最强面试题整理第二弹:Python 进阶面试题(附答案)
Posted Rocky0429
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了最强面试题整理第二弹:Python 进阶面试题(附答案)相关的知识,希望对你有一定的参考价值。
大家好呀,我是 Rocky0429。
Python 面试的时候,会涉及到很多的八股文,我结合自己的经验,整理Python 最强面试题。
Python 最强面试题主要包括以下几方面:
- Python 基础(已完成)
- Python 进阶(已完成)
- Python 后台开发
- 爬虫
- 机器学习
对每道面试题会附带详细的答案,无论是准备面试还是自己学习,这份面试题绝对值得你去看,去学习。
1、Python 中类方法、类实例方法、静态方法有何区别?
类方法:是类对象的方法,在定义时需要在上方使用“@classmethod”进行装饰,形参为 cls,表示类对象,类对象和实例对象都可调用
类实例方法:是类实例化对象的方法,只有实例对象可以调用,形参为 self,指代对象本身
静态方法:是一个任意函数,在其上方使用“@staticmethod”进行装饰,可以用对象直接调用,静态方法实际上跟该类没有太大关系
2、Python 的内存管理机制及调优手段?
内存管理机制:引用计数、垃圾回收、内存池。
引用计数
引用计数是一种非常高效的内存管理手段, 当一个 Python 对象被引用时其引用计数增加 1, 当其不再被一个变量引用时则计数减 1. 当引用计数等于 0 时对象被删除。
垃圾回收
(1) 引用计数
引用计数也是一种垃圾收集机制,而且也是一种最直观,最简单的垃圾收集技术。当 Python 的某
个对象的引用计数降为 0 时,说明没有任何引用指向该对象,该对象就成为要被回收的垃圾了。比如
某个新建对象,它被分配给某个引用,对象的引用计数变为 1。如果引用被删除,对象的引用计数为 0,
那么该对象就可以被垃圾回收。不过如果出现循环引用的话,引用计数机制就不再起有效的作用了
(2)标记清除
如果两个对象的引用计数都为 1,但是仅仅存在他们之间的循环引用,那么这两个对象都是需要被
回收的,也就是说,它们的引用计数虽然表现为非 0,但实际上有效的引用计数为 0。所以先将循环引
用摘掉,就会得出这两个对象的有效计数。
(3) 分代回收
从前面“标记-清除”这样的垃圾收集机制来看,这种垃圾收集机制所带来的额外操作实际上与系统
中总的内存块的数量是相关的,当需要回收的内存块越多时,垃圾检测带来的额外操作就越多,而垃圾
回收带来的额外操作就越少;反之,当需回收的内存块越少时,垃圾检测就将比垃圾回收带来更少的额
外操作。
举个例子:
当某些内存块 M 经过了 3 次垃圾收集的清洗之后还存活时,我们就将内存块 M 划到一个集合 A 中去,而新分配的内存都划分到集合 B 中去。当垃圾收集开始工作时,大多数情况都只对集合 B 进行垃圾回收,而对集合 A 进行垃圾回收要隔相当长一段时间后才进行,这就使得垃圾收集机制需要处理的内存少了,效率自然就提高了。在这个过程中,集合 B 中的某些内存块由于存活时间长而会被转移到集合 A 中,当然,集合 A 中实际上也存在一些垃圾,这些垃圾的回收会因为这种分代的机制而被延迟。
内存池
(1) Python 的内存机制呈现金字塔形状,-1,-2 层主要有操作系统进行操作
(2) 第 0 层是 C 中的 malloc,free 等内存分配和释放函数进行操作
(3)第 1 层和第 2 层是内存池,有 Python 的接口函数 PyMem_Malloc 函数实现,当对象小于
256K 时有该层直接分配内存
(4) 第 3 层是最上层,也就是我们对 Python 对象的直接操作
Python 在运行期间会大量地执行 malloc 和 free 的操作,频繁地在用户态和核心态之间进行切换,这将严重影响 Python 的执行效率。为了加速 Python 的执行效率,Python 引入了一个内存池机制,用于管理对小块内存的申请和释放。
Python 内部默认的小块内存与大块内存的分界点定在 256 个字节,当申请的内存小于 256 字节时,PyObject_Malloc 会在内存池中申请内存;当申请的内存大于 256 字节时,PyObject_Malloc 的行为将蜕化为 malloc 的行为。当然,通过修改 Python 源代码,我们可以改变这个默认值,从而改变 Python 的默认内存管理行为。
3、内存泄露是什么?如何避免?
由于疏忽或错误造成程序未能释放已经不再使用的内存的情况。
内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,失去了对该段内存的控制,因而造成了内存的浪费。导致程序运行速度减慢甚至系统崩溃等严重后果。
del() 函数的对象间的循环引用是导致内存泄漏的主凶。
不使用一个对象时使用:del object 来删除一个对象的引用计数就可以有效防止内存泄漏问题。
通过 Python 扩展模块 gc 来查看不能回收的对象的详细信息。
可以通过 sys.getrefcount(obj) 来获取对象的引用计数,并根据返回值是否为 0 来判断是否内存
泄漏。
4、Python 函数调用的时候参数的传递方式是值传递还是引用传递?
Python 的参数传递有:位置参数、默认参数、可变参数、关键字参数。函数的传值到底是值传递还是引用传递,要分情况:
不可变参数用值传递
像整数和字符串这样的不可变对象,是通过拷贝进行传递的,因为你无论如何都不可能在原处改变不可变对象
可变参数是引用传递的
比如像列表,字典这样的对象是通过引用传递、和 C 语言里面的用指针传递数组很相似,可变对象能在函数内部改变。
5、对缺省参数的理解?
缺省参数指在调用函数的时候没有传入参数的情况下,调用默认的参数,在调用函数的同时赋值时,所传入的参数会替代默认参数。
*args 是不定长参数,他可以表示输入参数是不确定的,可以是任意多个。
**kwargs 是关键字参数,赋值的时候是以键 = 值的方式,参数是可以任意多对在定义函数的时候
不确定会有多少参数会传入时,就可以使用两个参数。
补充
*args
如果你之前学过 C 或者 C++,看到星号的第一反应可能会认为这个与指针相关,然后就开始方了,其实放宽心,Python 中是没有指针这个概念的。
在 Python 中我们使用星号收集位置参数,请看下面的例子:
>>> def fun(x,*args):
... print(x)
... res = x
... print(args)
... for i in args:
... res += i
... return res
...
>>> print(fun(1,2,3,4,5,6))
上述例子中,函数的参数有两个,但是我们在输出的时候赋给函数的参数个数不仅仅是两个,让我们来运行这个代码,得到如下的结果:
1
(2, 3, 4, 5, 6)
21
从上面我们可以看出,参数 x 得到的值是 1,参数 args 得到的是一个元组 (2,3,4,5,6) ,由此我们可以得出,如果输入的参数个数不确定,其它的参数全部通过 *args 以元组的方式由 arg 收集起来。
为了更能明显的看出 *args,我们下面用一个简单的函数来表示:
>>> def print_args(*args):
... print(args)
...
接下来我传入不同的值,通过参数 *args 得到的结果我们来看一下:
>>> print_args(1,2,3)
(1, 2, 3)
>>> print_args('abc','def','ghi')
('abc', 'def', 'ghi')
>>> print_args('abc',['a','b','c'],1,2,3)
('abc', ['a', 'b', 'c'], 1, 2, 3)
不管是什么,都可以一股脑的塞进元组里,即使只有一个值,也是用元组收集,所以还记得在元组里一个元素的时候的形式吗?元组中如果只有一个元素,该元素的后面要有一个逗号。
那么如果不给 *args 传值呢?
>>> def print_args(*args):
... print(args)
...
>>> print_args()
()
答案就是这时候 *args 收集到的是一个空的元组。
最后提醒一点的是,当使用星号的时候,不一定要把元组参数命名为 args,但这个是 Python 中的一个常见做法。
**kwargs
使用两个星号是收集关键字参数,可以将参数收集到一个字典中,参数的名字是字典的 “键”,对应的参数的值是字典的 “值”。请看下面的例子:
>>> def print_kwargs(**kwargs):
... print(kwargs)
...
>>> print_kwargs(a = 'lee',b = 'sir',c = 'man')
'a': 'lee', 'b': 'sir', 'c': 'man'
由例子可以看出,在函数内部,kwargs 是一个字典。
看到这的时候,可能聪明的你会想,参数不是具有不确定型吗?如何知道参数到底会用什么样的方式传值?其实这个很好办,把 *args 和 **kwargs 综合起来就好了啊,请看下面的操作:
>>> def print_all(x,y,*args,**kwargs):
... print(x)
... print(y)
... print(args)
... print(kwargs)
...
>>> print_all('lee',1234)
lee
1234
()
>>> print_all('lee',1,2,3,4,5)
lee
1
(2, 3, 4, 5)
>>> print_all('lee',1,2,3,4,5,like = 'python')
lee
1
(2, 3, 4, 5)
'like': 'python'
如此这般,我们就可以应对各种各样奇葩无聊的参数请求了。当然在这还是要说的是,这里的关键字参数命名不一定要是 kwargs,但这个是通常做法。
6、为什么函数名字可以当做参数用?
Python 中一切皆对象,函数名是函数在内存中的空间,也是一个对象。
7、Python 中 pass 语句的作用是什么?
在编写代码时只写框架思路,具体实现还未编写就可以用 pass 进行占位,使程序不报错,不会进行任何操作。
8、面向对象中super的作用?
super() 函数是用于调用父类(超类)的一个方法。
super 是用来解决多重继承问题的,直接用类名调用父类方法在使用单继承的时候没问题,但是如果使用多继承,会涉及到查找顺序(MRO)、重复调用(钻石继承)等种种问题。
MRO 就是类的方法解析顺序表, 其实也就是继承父类方法时的顺序表。
作用:
-
根据 mro 的顺序执行方法
-
主动执行 Base 类的方法
9、是否使用过functools中的函数?其作用是什么?
Python的functools模块用以为可调用对象(callable objects)定义高阶函数或操作。
简单地说,就是基于已有的函数定义新的函数。
所谓高阶函数,就是以函数作为输入参数,返回也是函数。
10、json序列化时,默认遇到中文会转换成unicode,如果想要保留中文怎么办?
import json
a = json.dumps("ddf": "你好", ensure_ascii=False)
print(a)
# "ddf": "你好"
11、什么是断言?应用场景?
assert断言——声明其布尔值必须为真判定,发生异常则为假。
info =
info['name'] = 'egon'
info['age'] = 18
# 用assert取代上述代码:
assert ('name' in info) and ('age' in info)
设置一个断言目的就是要求必须实现某个条件。
12、有用过with statement吗?它的好处是什么?
with语句的作用是通过某种方式简化异常处理,它是所谓的上下文管理器的一种
用法举例如下:
with open('output.txt', 'w') as f:
f.write('Hi there!')
当你要成对执行两个相关的操作的时候,这样就很方便,以上便是经典例子,with语句会在嵌套的代码执行之后,自动关闭文件。
这种做法的还有另一个优势就是,无论嵌套的代码是以何种方式结束的,它都关闭文件。
如果在嵌套的代码中发生异常,它能够在外部exception handler catch异常前关闭文件。
如果嵌套代码有return/continue/break语句,它同样能够关闭文件。
13、简述 Python 在异常处理中,else 和 finally 的作用分别是什么?
如果一个 Try - exception 中,没有发生异常,即 exception 没有执行,那么将会执行 else 语句的内容。反之,如果触发了 Try - exception(异常在 exception 中被定义),那么将会执行exception
中的内容,而不执行 else 中的内容。
如果 try 中的异常没有在 exception 中被指出,那么系统将会抛出 Traceback(默认错误代码),并且终止程序,接下来的所有代码都不会被执行,但如果有 Finally 关键字,则会在程序抛出 Traceback 之前(程序最后一口气的时候),执行 finally 中的语句。这个方法在某些必须要结束的操作中颇为有用,如释放文件句柄,或释放内存空间等。
14、map 函数和 reduce 函数?
(1) 从参数方面来讲:
map()包含两个参数,第一个参数是一个函数,第二个是序列(列表 或元组)。其中,函数(即 map 的第一个参数位置的函数)可以接收一个或多个参数。
reduce()第一个参数是函数,第二个是序列(列表或元组)。但是,其函数必须接收两个参数。
(2) 从对传进去的数值作用来讲:
map()是将传入的函数依次作用到序列的每个元素,每个元素都是独自被函数“作用”一次 。
reduce()是将传人的函数作用在序列的第一个元素得到结果后,把这个结果继续与下一个元素作用(累积计算)。
补充 Python 特殊函数
lambda 函数
lambda 是一个可以只用一行就能解决问题的函数,让我们先看下面的例子:
>>> def add(x):
... x += 1
... return x
...
>>> numbers = range(5)
>>> list(numbers)
[0, 1, 2, 3, 4]
>>> new_numbers = []
>>> for i in numbers:
... new_numbers.append(add(i))
...
>>> new_numbers
[1, 2, 3, 4, 5]
在上面的这个例子中,函数 add() 充当了一个中间角色,当然上面的例子也可以如下实现:
>>> new_numbers = [i+1 for i in numbers]
>>> new_numbers
[1, 2, 3, 4, 5]
首先我要说,上面的列表解析式其实是很好用的,但是我偏偏要用 lambda 这个函数代替 add(x) :
>>> lamb = lambda x: x+1
>>> new_numbers = []
>>> for i in numbers:
... new_numbers.append(lamb(i))
...
>>> new_numbers
[1, 2, 3, 4, 5]
在这里的 lamb 就相当于 add(x) ,lamb = lambda x : x+1 就相当于 add(x) 里的代码块。下面再写几个应用 lambda 的小例子:
>>> lamb = lambda x,y : x + y
>>> lamb(1,2)
3
>>> lamb1 = lambda x : x ** 2
>>> lamb1(5)
25
由上面的例子我们可以总结一下 lambda 函数的具体使用方法:lambda 后面直接跟变量,变脸后面是冒号,冒号后面是表达式,表达式的计算结果就是本函数的返回值。
在这里有一点需要提醒的是,虽然 lambda 函数可以接收任意多的参数并且返回单个表达式的值,但是 lambda 函数不能包含命令且包含的表达式不能超过一个。如果你需要更多复杂的东西,你应该去定义一个函数。
lambda 作为一个只有一行的函数,在你具体的编程实践中可以选择使用,虽然在性能上没什么提升,但是看着舒服呀。
map 函数
我们在上面讲 lambda 的时候用的例子,其实 map 也可以实现,请看下面的操作:
>>> numbers = [0,1,2,3,4]
>>> map(add,numbers)
[1, 2, 3, 4, 5]
>>> map(lambda x: x + 1,numbers)
[1, 2, 3, 4, 5]
map 是 Python 的一个内置函数,它的基本格式是:map(func, seq)。
func 是一个函数对象,seq 是一个序列对象,在执行的时候,seq 中的每个元素按照从左到右的顺序依次被取出来,塞到 func 函数里面,并将 func 的返回值依次存到一个列表里。
对于 map 要主要理解以下几个点就好了:
1.对可迭代的对象中的每一个元素,依次使用 fun 的方法(其实本质上就是一个 for 循环)。
2.将所有的结果返回一个 map 对象,这个对象是个迭代器。
我们接下来做一个简单的小题目:将两个列表中的对应项加起来,把结果返回在一个列表里,我们用 map 来做,如果你做完了,请往下看:
>>> list1 = [1,2,3,4]
>>> list2 = [5,6,7,8]
>>> list(map(lambda x,y: x + y,list1,list2))
[6, 8, 10, 12]
你看上面,是不是很简单?其实这个还看不出 map 的方便来,因为用 for 同样也不麻烦,要是你有这样的想法的话,那么请看下面:
>>> list1 = [1,2,3,4]
>>> list2 = [5,6,7,8]
>>> list3 = [9,10,11,12]
>>> list(map(lambda x,y,z : x + y + z,list1,list2,list3))
[15, 18, 21, 24]
你看三个呢?是不是用 for 的话就稍显麻烦了?那么我们在想如果是 四个,五个乃至更多呢?这就显示出 map 的简洁优雅了,并且 map 还不和 lambda 一样对性能没有什么提高,map 在性能上的优势也是杠杠的。
filter 函数
filter 翻译过来的意思是 “过滤器”,在 Python 中,它也确实是起到的是过滤器的作用。这个解释起来略微麻烦,还是直接上代码的好,在代码中体会用法是我在所有的文章里一直在体现的:
>>> numbers = range(-4,4)
>>> list(filter(lambda x: x > 0,numbers))
[1, 2, 3]
上面的例子其实和下面的代码是等价的:
>>> [x for x in numbers if x > 0]
[1, 2, 3]
然后我们再来写一个例子体会一下:
>>> list(filter(lambda x: x != 'o','Rocky0429'))
['R', 'c', 'k', 'y', '0', '4', '2', '9']
reduce 函数
我在之前的文章中很多次都说过,我的代码都是用 Python3 版本的。在 Python3 中,reduce 函数被放到 functools 模块里,在 Python2 中还是在全局命名空间。
同样我先用一个例子来跑一下,我们来看看怎么用:
>>> reduce(lambda x,y: x+y,[1,2,3,4])
10
reduce 函数的第一个参数是一个函数,第二个参数是序列类型的对象,将函数按照从左到右的顺序作用在序列上。如果你还不理解的话,我们下面可以对比一下它和 map 的区别:
>>> list1 = [1,2,3,4]
>>> list2 = [5,6,7,8]
>>> list(map(lambda x,y: x + y,list1,list2))
[6, 8, 10, 12]
对比上面的两个例子,就知道两者的区别,map 相当于是上下运算的,而 reduce 是从左到右逐个元素进行运算。
15、递归函数停止的条件?
递归的终止条件一般定义在递归函数内部,在递归调用前要做一个条件判断,根据判断的结果选择是继续调用自身,还是 return;返回终止递归。
终止的条件:
(1) 判断递归的次数是否达到某一限定值
(2) 判断运算的结果是否达到某个范围等,根据设计的目的来选择
16、回调函数,如何通信的?
回调函数是把函数的指针(地址)作为参数传递给另一个函数,将整个函数当作一个对象,赋值给调用的函数。
17、 _setattr_,_getattr_,__delattr__函数使用详解?
1.setattr(self,name,value):如果想要给 name 赋值的话,就需要调用这个方法。
2.getattr(self,name):如果 name 被访问且它又不存在,那么这个方法将被调用。
3.delattr(self,name):如果要删除 name 的话,这个方法就要被调用了。
下面我们用例子来演示一下:
>>> class Sample:
... def __getattr__(self,name):
... print('hello getattr')
... def __setattr__(self,name,value):
... print('hello setattr')
... self.__dict__[name] = value
...
上面的例子中类 Sample 只有两个方法,下面让我们实例化一下:
>>> s = Sample()
>>> s.x
hello getattr
s.x 这个实例属性本来是不存在的,但是由于类中有了 getattr(self,name) 方法,当发现属性 x 不存在于对象的 dict 中时,就调用了 getattr,也就是所谓的「拦截成员」。
>>> s.x = 7
hello setattr
同理,给对象的属性赋值的时候,调用了 setattr(self,name,value) 方法,这个方法中有 self.dict[name] = value,通过这个就将属性和数据存到了对象 dict 中。如果再调用这个属性的话,会成为下面这样:
>>> s.x
7
出现这种结果的原因是它已经存在于对象的 dict 中了。
看了上面的两个,你是不是觉得上面的方法很有魔力呢?这就是「黑魔法」,但是它具体有什么应用呢?我们接着来看:
class Rectangle:
"""
the width and length of Rectangle
"""
def __init__(self):
self.width = 0
self.length = 0
def setSize(self,size):
self.width, self.length = size
def getSize(self):
return self.width, self.length
if __name__ == "__main__":
r = Rectangle()
r.width = 3
r.length = 4
print(r.getSize())
print(r.setSize((30,40)))
print(r.width)
print(r.length)
上面是我根据一个很有名的例子改编的,你可以先想一下结果,想完以后可以接着往下看:
(3, 4)
30
40
这段代码运行的结果如上面所示,作为一个强迫证的码农,对于这种可以改进的程序当然不能容忍。我们在上面介绍的特殊方法,我们一定要学以致用,虽然重新写的不一定比原来的好,但我们还是要尝试去用:
class NewRectangle:
"""
the width and length of Rectangle
"""
def __init__(self):
self.width = 0
self.length = 0
def __setattr__(self, name, value):
if name == 'size':
self.width, self.length = value
else:
self.__dict__[name] = value
def __getattr__(self, name):
if name == 'size':
return self.width, self.length
else:
return AttributeError
if __name__ == "__main__":
r = NewRectangle()
r.width = 3
r.length = 4
print(r.size)
r.size = 30,40
print(r.width)
print(r.length)
我们来看一下运行的结果:
(3, 4)
30
40
我们可以看到,除了类的写法变了以外,调用的方式没有变,结果也没有变。
18、请描述抽象类和接口类的区别和联系?
(1) 抽象类
规定了一系列的方法,并规定了必须由继承类实现的方法。由于有抽象方法的存在,所以抽象类不能实例化。可以将抽象类理解为毛坯房,门窗、墙面的样式由你自己来定,所以抽象类与作为基类的普通类的区别在于约束性更强。
(2) 接口类
与抽象类很相似,表现在接口中定义的方法,必须由引用类实现,但他与抽象类的根本区别在于用途:与不同个体间沟通的规则(方法),你要进宿舍需要有钥匙,这个钥匙就是你与宿舍的接口,你的同室也有这个接口,所以他也能进入宿舍,你用手机通话,那么手机就是你与他人交流的接口。
(3) 区别和关联
-
接口是抽象类的变体,接口中所有的方法都是抽象的。而抽象类中可以有非抽象方法。抽象类是声明方法的存在而不去实现它的类。
-
接口可以继承,抽象类不行。
-
接口定义方法,没有实现的代码,而抽象类可以实现部分方法。
-
接口中基本数据类型为 static 而抽类象不是。
-
接口可以继承,抽象类不行。
-
可以在一个类中同时实现多个接口。
-
接口的使用方式通过 implements 关键字进行,抽象类则是通过继承 extends 关键字进行。
19、请描述方法重载与方法重写?
(1)方法重载
是在一个类里面,方法名字相同,而参数不同。返回类型呢?可以相同也可以不同。重载是让类以统一的方式处理不同类型数据的一种手段。
(2) 方法重写
子类不想原封不动地继承父类的方法,而是想作一定的修改,这就需要采用方法的重写。方法重写又称方法覆盖。
20、什么是 lambda 函数? 有什么好处?
lambda 函数是一个可以接收任意多个参数(包括可选参数)并且返回单个表达式值的函数
1、lambda 函数比较轻便,即用即仍,很适合需要完成一项功能,但是此功能只在此一处使用,连名字都很随意的情况下
2、匿名函数,一般用来给 filter, map 这样的函数式编程服务
3、作为回调函数,传递给某些应用,比如消息处理
补充
lambda 是一个可以只用一行就能解决问题的函数,让我们先看下面的例子:
>>> def add(x):
... x += 1
... return x
...
>>> numbers = range(5)
>>> list(numbers)
[0, 1, 2, 3, 4]
>>> new_numbers = []
>>> for i in numbers:
... new_numbers.append(add(i))
...
>>> new_numbers
[1, 2, 3, 4, 5]
在上面的这个例子中,函数 add() 充当了一个中间角色,当然上面的例子也可以如下实现:
>>> new_numbers = [i+1 for i in numbers]
>>> new_numbers
[1, 2, 3, 4, 5]
首先我要说,上面的列表解析式其实是很好用的,但是我偏偏要用 lambda 这个函数代替 add(x) :
>>> lamb = lambda x: x+1
>>> new_numbers = []
>>> for i in numbers:
... new_numbers.append(lamb(i))
...
>>> new_numbers
[1, 2, 3, 4, 5]
在这里的 lamb 就相当于 add(x) ,lamb = lambda x : x+1 就相当于 add(x) 里的代码块。下面再写几个应用 lambda 的小例子:
>>> lamb = lambda x,y : x + y
>>> lamb(1,2)
3
>>> lamb1 = lambda x : x ** 2
>>> lamb1(5)
25
由上面的例子我们可以总结一下 lambda 函数的具体使用方法:lambda 后面直接跟变量,变脸后面是冒号,冒号后面是表达式,表达式的计算结果就是本函数的返回值。
在这里有一点需要提醒的是,虽然 lambda 函数可以接收任意多的参数并且返回单个表达式的值,但是 lambda 函数不能包含命令且包含的表达式不能超过一个。如果你需要更多复杂的东西,你应该去定义一个函数。
lambda 作为一个只有一行的函数,在你具体的编程实践中可以选择使用,虽然在性能上没什么提升,但是看着舒服呀。
21、单例模式的应用场景有哪些?
单例模式应用的场景一般发现在以下条件下:
(1)资源共享的情况下,避免由于资源操作时导致的性能或损耗等。如日志文件,应用配置。
(2)控制资源的情况下,方便资源之间的互相通信。如线程池等。 1.网站的计数器 2.应用配置 3.多线程池 4.数据库配置,数据库连接池 5.应用程序的日志应用…
补充
01.单例设计模式
「单例设计模式」估计对很多人来说都是一个陌生的概念,其实它就环绕在你的身边。比如我们每天必用的听歌软件,同一时间只能播放一首歌曲,所以对于一个听歌的软件来说,负责音乐播放的对象只有一个;再比如打印机也是同样的道理,同一时间打印机也只能打印一份文件,同理负责打印的对象也只有一个。
结合说的听歌软件和打印机都只有唯一的一个对象,就很好理解「单例设计模式」。
单例设计模式确保一个类只有一个实例,并提供一个全局访问点。
「单例」就是单个实例,我们在定义完一个类的之后,一般使用「类名()」的方式创建一个对象,而单例设计模式解决的问题就是无论执行多少遍「类名()」,返回的对象内存地址永远是相同的。
02.new 方法
当我们使用「类名()」创建对象的时候,Python 解释器会帮我们做两件事情:第一件是为对象在内存分配空间,第二件是为对象进行初始化。初始化(init)我们已经学过了,那「分配空间」是哪一个方法呢?就是我们这一小节要介绍的 new 方法。
那这个 new 方法和单例设计模式有什么关系呢?单例设计模式返回的对象内存地址永远是相同的,这就意味着在内存中这个类的对象只能是唯一的一份,为达到这个效果,我们就要了解一下为对象分配空间的 new 方法。
明确了这个目的以后,接下来让我们看一下 new 方法。new 方法在内部其实做了两件时期:第一件事是为「对象分配空间」,第二件事是「把对象的引用返回给 Python 解释器」。当 Python 的解释器拿到了对象的引用之后,就会把对象的引用传递给 init 的第一个参数 self,init 拿到对象的引用之后,就可以在方法的内部,针对对象来定义实例属性。
这就是 new 方法和 init 方法的分工。
总结一下就是:之所以要学习 new 方法,就是因为需要对分配空间的方法进行改造,改造的目的就是为了当使用「类名()」创建对象的时候,无论执行多少次,在内存中永远只会创造出一个对象的实例,这样就可以达到单例设计模式的目的。
03.重写 new 方法
在这里我用一个 new 方法的重写来做一个演练:首先定义一个打印机的类,然后在类里重写一下 new 方法。通过对这个方法的重写来强化一下 new 方法要做的两件事情:在内存中分配内存空间 & 返回对象的引用。同时验证一下,当我们使用「类名()」创建对象的时候,Python 解释器会自动帮我们调用 new 方法。
首先我们先定义一个打印机类 Printer,并创建一个实例:
class Printer():
def __init__(self):
print("打印机初始化")
# 创建打印机对象
printer = Printer()
接下来就是重写 new 方法,在此之前,先说一下注意事项,只要⚠️了这几点,重写 new 就没什么难度:
重写 new 方法一定要返回对象的引用,否则 Python 的解释器得不到分配了空间的对象引用,就不会调用对象的初始化方法;
new 是一个静态方法,在调用时需要主动传递 cls 参数。
# 重写 __new__ 方法
class Printer():
def __new__(cls, *args, **kwargs):
# 可以接收三个参数
# 三个参数从左到右依次是 class,多值元组参数,多值的字典参数
print("this is rewrite new")
instance = super().__new__(cls)
return instance
def __init__(self):
print("打印机初始化")
# 创建打印机对象
player = Printer()
print(player)
上述代码对 new 方法进行了重写,我们先来看一下输出结果:
this is rewrite new
打印机初始化
<__main__.Printer object at 0x10fcd2ba8>
上述的结果打印出了 new 方法和 init 方法里的内容,同时还打印了类的内存地址,顺序正好是我们在之前说过的。new 方法里的三行代码正好做了在本小节开头所说的三件事:
-
print(this is rewrite new):证明了创建对象时,new 方法会被自动调用;
-
instance = super().new(cls):为对象分配内存空间(因为 new 本身就有为对象分配内存空间的能力,所以在这直接调用父类的方法即可);
-
return instance:返回对象的引用。
04.设计单例模式
说了这么多,接下来就让我们用单例模式来设计一个单例类。乍一看单例类看起来比一般的类更唬人,但其实就是差别在一点:单例类在创建对象的时候,无论我们调用多少次创建对象的方法,得到的结果都是内存中唯一的对象。
可能到这有人会有疑惑:怎么知道用这个类创建出来的对象是同一个对象呢?其实非常的简单,我们只需要多调用几次创建对象的方法,然后输出一下方法的返回结果,如果内存地址是相同的,说明多次调用方法返回的结果,本质上还是同一个对象。
class Printer():
pass
printer1 = Printer()
print(printer1)
printer2 = Printer()
print(printer2)
上面是一个一般类的多次调用,打印的结果如下所示:
<__main__.Printer object at 0x10a940780>
<__main__.Printer object at 0x10a94d3c8>
可以看出,一般类中多次调用的内存地址不同(即 printer1 和 printer2 是两个完全不同的对象),而单例设计模式设计的单例类 Printer(),要求是无论调用多少次创建对象的方法,控制台打印出来的内存地址都是相同的。
那么我们该怎么实现呢?其实很简单,就是多加一个「类属性」,用这个类属性来记录「单例对象的引用」。
为什么要这样呢?其实我们一步一步的来想,当我们写完一个类,运行程序的时候,内存中其实是没有这个类创建的对象的,我们必须调用创建对象的方法,内存中才会有第一个对象。在重写 new 方法的时候,我们用 instance = super().new(cls) ,为对象分配内存空间,同时用 istance 类属性记录父类方法的返回结果,这就是第一个「对象在内存中的返回地址」。当我们再次调用创建对象的方法时,因为第一个对象已经存在了,我们直接把第一个对象的引用做一个返回,而不用再调用 super().new(cls) 分配空间这个方法,所以就不会在内存中为这个类的其它对象分配额外的内存空间,而只是把之前记录的第一个对象的引用做一个返回,这样就能做到无论调用多少次创建对象的方法,我们永远得到的是创建的第一个对象的引用。
这个就是使用单例设计模式解决在内存中只创建唯一一个实例的解决办法。下面我就根据上面所说的,来完成单例设计模式。
class Printer():
instance = None
def __new__(cls, *args, **kwargs):
if cls.instance is None:
cls.instance = super().__new__(cls)
return cls.instance
printer1 = Printer()
print(printer1)
printer2 = Printer()
print(printer2)
上述代码很简短,首先给类属性复制为 None,在 new 方法内部,如果 instance 为 None,证明第一个对象还没有创建,那么就为第一个对象分配内存空间,如果 instance 不为 None,直接把类属性中保存的第一个对象的引用直接返回,这样在外界无论调用多少次创建对象的方法,得到的对象的内存地址都是相同的。
下面我们运行一下程序,来看一下结果是不是能印证我们的说法:
<__main__.Printer object at 0x10f3223c8>
<__main__.Printer object at 0x10f3223c8>
上述输出的两个结果可以看出地址完全一样,这说明 printer1 和 printer2 本质上是相同的一个对象。
22、什么是闭包?
我们都知道在数学中有闭包的概念,但此处我要说的闭包是计算机编程语言中的概念,它被广泛的使用于函数式编程。
关于闭包的概念,官方的定义颇为严格,也很难理解,在《Python语言及其应用》一书中关于闭包的解释我觉得比较好 – 闭包是一个可以由另一个函数动态生成的函数,并且可以改变和存储函数外创建的变量的值。乍一看,好像还是比较很难懂,下面我用一个简单的例子来解释一下:
>>> a = 1
>>> def fun():
... print(a)
...
>>> fun()
1
>>> def fun1():
... b = 1
...
>>> print(b)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name 'b' is not defined
毋庸置疑,第一段程序是可以运行成功的,a = 1 定义的变量在函数里可以被调用,但是反过来,第二段程序则出现了报错。
在函数 fun() 里可以直接使用外面的 a = 1,但是在函数 fun1() 外面不能使用它里面所定义的 b = 1,如果我们根据作用域的关系来解释,是没有什么异议的,但是如果在某种特殊情况下,我们必须要在函数外面使用函数里面的变量,该怎么办呢?
我们先来看下面的例子:
>>> def fun():
... a = 1
... def fun1():
... return a
... return fun1
...
>>> f = fun()
>>> print(f())
1
如果你看过昨天的文章,你一定觉得的很眼熟,上述的本质就是我们昨天所讲的嵌套函数。
在函数 fun() 里面,有 a = 1 和 函数 fun1() ,它们两个都在函数 fun() 的环境里面,但是它们两个是互不干扰的,所以 a 相对于 fun1() 来说是自由变量,并且在函数 fun1() 中应用了这个自由变量 – 这个 fun1() 就是我们所定义的闭包。
闭包实际上就是一个函数,但是这个函数要具有 1.定义在另外一个函数里面(嵌套函数);2.引用其所在环境的自由变量。
上述例子通过闭包在 fun() 执行完毕时,a = 1依然可以在 f() 中,即 fun1() 函数中存在,并没有被收回,所以 print(f()) 才得到了结果。
当我们在某些时候需要对事务做更高层次的抽象,用闭包会相当舒服。比如我们要写一个二元一次函数,如果不使用闭包的话相信你可以轻而易举的写出来,下面让我们来用闭包的方式完成这个一元二次方程:
>>> def fun(a,b,c):
... def para(x):
... return a*x**2 + b*x + c
... return para
...
>>> f = fun(1,2,3)
>>> print(f(2))
11
上面的函数中,f = fun(1,2,3) 定义了一个一元二次函数的函数对象,x^2 + 2x + 3,如果要计算 x = 2 ,该一元二次函数的值,只需要计算 f(2) 即可,这种写法是不是看起来更简洁一些。
23、什么是装饰器?
「装饰器」作为 Python 高级语言特性中的重要部分,是修改函数的一种超级便捷的方式,适当使用能够有效提高代码的可读性和可维护性,非常的便利灵活。
「装饰器」本质上就是一个函数,这个函数的特点是可以接受其它的函数当作它的参数,并将其替换成一个新的函数(即返回给另一个函数)。
可能现在这么看的话有点懵,为了深入理解「装饰器」的原理,我们首先先要搞明白「什么是函数对象」,「什么是嵌套函数」,「什么是闭包」。关于这三个问题我在很久以前的文章中已经写过了,你只需要点击下面的链接去看就好了,这也是面试中常问的知识哦:
装饰器
搞明白上面的三个问题,其实简单点来说就是告诉你:函数可以赋值给变量,函数可嵌套,函数对象可以作为另一个函数的参数。
首先我们来看一个例子,在这个例子中我们用到了前面列出来的所有知识:
def first(fun):
def second():
print('start')
fun()
print('end')
print fun.__name__
return second
def man():
print('i am a man()')
f = first(man)
f()
上述代码的执行结果如下所示:
start
i am a man()
end
man
上面的程序中,这个就是 first 函数接收了 man 函数作为参数,并将 man 函数以一个新的函数进行替换。看到这你有没有发现,这个和我在文章刚开始时所说的「装饰器」的描述是一样的。既然这样的话,那我们就把上述的代码改造成符合 Python 装饰器的定义和用法的样子,具体如下所示:
def first(func):
def second():
print('start')
func()
print('end')
print (func.__name__)
return second
@first
def man():
print('i am a man()')
man()
上面这段代码和之前的代码的作用一模一样。区别在于之前的代码直接“明目张胆”的使用 first 函数去封装 man 函数,而上面这个是用了「语法糖」来封装 man 函数。至于什么是语法糖,不用细去追究,你就知道是类似「@first」这种形式的东西就好了。
在上述代码中「@frist」在 man 函数的上面,表示对 man 函数使用 first 装饰器。「@」 是装饰器的语法,「first」是装饰器的名称。
下面我们再来看一个复杂点的例子,用这个例子我们来更好的理解一下「装饰器」的使用以及它作为 Python 语言高级特性被人津津乐道的部分:
def check_admin(username):
if username != 'admin':
raise Exception('This user do not have permission')
class Stack:
def __init__(self):
self.item = []
def push(self,username,item):
check_admin(username=username)
self.item.append(item)
def pop(self,username):
check_admin(username=username)
if not self.item:
raise Exception('NO elem in stack')
return self.item.pop()
上述实现了一个特殊的栈,特殊在多了检查当前用户是否为 admin 这步判断,如果当前用户不是 admin,则抛出异常。上面的代码中将检查当前用户的身份写成了一个独立的函数 check_admin,在 push 和 pop 中只需要调用这个函数即可。这种方式增强了代码的可读性,减少了代码冗余,希望大家在编程的时候可以具有这种意识。
下面我们来看看上述代码用装饰器来写成的效果:
def check_admin(func):
def wrapper(*args, **kwargs):
if kwargs.get('username') != 'admin':
raise Exception('This user do not have permission')
return func(*args, **kwargs)
return wrapper
class Stack:
def __init__(self):
self.item = []
@check_admin
def push(self,username,item):
self.item.append(item)
@check_admin
def pop(self,username):
if not self.item:
raise Exception('NO elem in stack')
return self.item.pop()
PS:可能很多人对 *args 和 **kwargs 不太熟悉,详情请戳下面的链接:
对比一下使用「装饰器」和不使用装饰器的两种写法,乍一看,好像使用「装饰器」以后代码的行数更多了,但是你有没有发现代码看起来好像更容易理解了一些。在没有装饰器的时候,我们先看到的是 check_admin 这个函数,我们得先去想这个函数是干嘛的,然后看到的才是对栈的操作;而使用装饰器的时候,我们上来看到的就是对栈的操作语句,至于 check_admin 完全不会干扰到我们对当前函数的理解,所以使用了装饰器可读性更好了一些。
就和我在之前的文章中所讲的「生成器」那样,虽然 Python 的高级语言特性好用,但也不能乱用。装饰器的语法复杂,通过我们在上面缩写的装饰器就可以看出,它写完以后是很难调试的,并且使用「装饰器」的程序的速度会比不使用装饰器的程序更慢,所以还是要具体场景具体看待。
24、函数装饰器有什么作用?
装饰器本质上是一个 Python 函数,它可以在让其他函数在不需要做任何代码的变动的前提下增加额外的功能。装饰器的返回值也是一个函数的对象,它经常用于有切面需求的场景。 比如:插入日志、性能测试、事务处理、缓存、权限的校验等场景 有了装饰器就可以抽离出大量的与函数功能本身无关的雷同代码并发并继续使用。
25、生成器、迭代器的区别
迭代器是一个更抽象的概念,任何对象,如果它的类有 next 方法和 iter 方法返回自己本身,对于 string、list、dict、tuple 等这类容器对象,使用 for 循环遍历是很方便的。在后台 for 语句对容器对象调用 iter()函数,iter()是 python 的内置函数。iter()会返回一个定义了 next()方法的迭代器对象,它在容器中逐个访问容器内元素,next()也是 python 的内置函数。在没有后续元素时,next()会抛出一个 StopIteration 异常。
生成器(Generator)是创建迭代器的简单而强大的工具。它们写起来就像是正规的函数,只是在需要返回数据的时候使用 yield 语句。每次 next()被调用时,生成器会返回它脱离的位置(它记忆语句最后一次执行的位置和所有的数据值)
区别:生成器能做到迭代器能做的所有事,而且因为自动创建了 iter()和 next()方法,生成器显得特别简洁,而且生成器也是高效的,使用生成器表达式取代列表解析可以同时节省内存。除了创建和保存程序状态的自动方法,当发生器终结时,还会自动抛出 StopIteration 异常。
26、多线程交互,访问数据,如果访问到了就不访问了,怎么避免重读?
创建一个已访问数据列表,用于存储已经访问过的数据,并加上互斥锁,在多线程访问数据的时候先查看数据是否已经在已访问的列表中,若已存在就直接跳过。
27、Python 中 yield 的用法?
yield 就是保存当前程序执行状态。你用 for 循环的时候,每次取一个元素的时候就会计算一次。用yield 的函数叫 generator,和 iterator 一样,它的好处是不用一次计算所有元素,而是用一次算一次,可以节省很多空间。generator每次计算需要上一次计算结果,所以用 yield,否则一 return,上次计算结果就没了。
补充
在 Python 中,定义生成器必须要使用 yield 这个关键词,yield 翻译成中文有「生产」这方面的意思。在 Python 中,它作为一个关键词,是生成器的标志。接下来我们来看一个例子:
>>> def f():
... yield 0
... yield 1
... yield 2
...
>>> f
<function f at 0x00000000004EC1E0>
上面是写了一个很简单的 f 函数,代码块是 3 个 yield 发起的语句,下面让我们来看看如何使用它:
>>> fa = f()
>>> fa
<generator object f at 0x0000000001DF1660>
>>> type(fa)
<class 'generator'>
上述操作可以看出,我们调用函数得到了一个生成器(generator)对象。
>>> dir(fa)
['__class__', '__del__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__iter__', '__le__', '__lt__', '__name__', '__ne__',
'__new__', '__next__', '__qualname__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'close', 'gi_code', 'gi_frame', 'gi_running', 'gi_yieldfrom', 'send', 'throw']
在上面我们看到了 iter() 和 next(),虽然我们在函数体内没有显示的写 iter() 和 next(),仅仅是写了 yield,但它就已经是「迭代器」了。既然如此,那我们就可以进行如下操作:
>>> fa = f()
>>> fa.__next__()
0
>>> fa.__next__()
1
>>> fa.__next__()
2
>>> fa.__next__()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
StopIteration
从上面的简单操作可以看出:含有 yield 关键词的函数 f() 是一个生成器对象,这个生成器对象也是迭代器。所以就有了这样的定义:把含有 yield 语句的函数称为生成器,生成器是一种用普通函数语法定义的迭代器。
通过上面的例子可以看出,这个生成器(即迭代器)在定义的过程中并没有昨天讲的迭代器那样写 iter(),而是只用了 yield 语句,之后一个普普通通的函数就神奇的成了生成器,同样也具备了迭代器的特性。
yield 语句的作用,就是在调用的时候返回相应的值。下面我来逐行的解释一下上面例子的运行过程:
1.fa = f():fa 引用生成器对象。
2.fa.next():生成器开始执行,遇到了第一个 yield,然后返回后面的 0,并且挂起(即暂停执行)。
3.fa.next():从上次暂停的位置开始,继续向下执行,遇到第二个 yield,返回后面的值 1,再挂起。
4.fa.next():重复上面的操作。
5.fa.next():从上次暂停的位置开始,继续向下执行,但是后面已经没有 yield 了,所以 next() 发生异常。
28、谈下 python 的 GIL
GIL 是python的全局解释器锁,同一进程中假如有多个线程运行,一个线程在运行python程序的时候会霸占python解释器(加了一把锁即GIL),使该进程内的其他线程无法运行,等该线程运行完后其他线程才能运行。如果线程运行过程中遇到耗时操作,则解释器锁解开,使其他线程运行。所以在多线程中,线程的运行仍是有先后顺序的,并不是同时进行。
多进程中因为每个进程都能被系统分配资源,相当于每个进程有了一个python解释器,所以多进程可以实现多个进程的同时运行,缺点是进程系统资源开销大
29、Python 中的可变对象和不可变对象?
不可变对象,该对象所指向的内存中的值不能被改变。当改变某个变量时候,由于其所指的值不能被改变,相当于把原来的值复制一份后再改变,这会开辟一个新的地址,变量再指向这个新的地址。
可变对象,该对象所指向的内存中的值可以被改变。变量(准确的说是引用)改变后,实际上是其所指的值直接发生改变,并没有发生复制行为,也没有开辟新的出地址,通俗点说就是原地改变。
Python 中,数值类型(int 和 float)、字符串 str、元组 tuple 都是不可变类型。而列表 list、字典 dict、集合 set 是可变类型。
30、一句话解释什么样的语言能够用装饰器?
函数可以作为参数传递的语言,可以使用装饰器
31、Python 中 is 和==的区别?
is 判断的是 a 对象是否就是 b 对象,是通过 id 来判断的。
==判断的是 a 对象的值是否和 b 对象的值相等,是通过 value 来判断的。
32、谈谈你对面向对象的理解?
面向对象是相对于面向过程而言的。
面向过程语言是一种基于功能分析的、以算法为中心的程序设计方法
面向对象是一种基于结构分析的、以数据为中心的程序设计思想。在面向对象语言中有一个有很重要东西,叫做类。面向对象有三大特性:封装、继承、多态。
33、Python 里 match 与 search 的区别?
match()函数只检测 RE 是不是在 string 的开始位置匹配
search()会扫描整个 string 查找匹配
也就是说 match()只有在 0 位置匹配成功的话才有返回,如果不是开始位置匹配成功的话,match()就返回 none。
34、用 Python 匹配 html g tag 的时候,<.> 和 <.?> 有什么区别?
<.*>是贪婪匹配,会从第一个“<”开始匹配,直到最后一个“>”中间所有的字符都会匹配到,中间可能会包含“<>”。
<.*?>是非贪婪匹配,从第一个“<”开始往后,遇到第一个“>”结束匹配,这中间的字符串都会匹配到,但是
不会有“<>”。
35、Python 中的进程与线程的使用场景?
多进程适合在 CPU 密集型操作(cpu 操作指令比较多,如位数多的浮点运算)。
多线程适合在 IO 密集型操作(读写数据操作较多的,比如爬虫)。
36、解释一下并行(parallel)和并发(concurrency)的区别?
并行
以上是关于最强面试题整理第二弹:Python 进阶面试题(附答案)的主要内容,如果未能解决你的问题,请参考以下文章