在Python中,通常会把符合一定“行为”的对象称呼为“类某某对象”。比如类文件对象,就是说实现了上下文协议,可以在with/as
中使用的对象,其行为与文件操作类似。
对应的,我们也可以创建一个类序列对象,指的是某一类可以像序列容器那样进行操作的对象。
这里使用和《Fluent Python》中所举的多维向量一致的例子,可能在具体命名和实现上有出入,但整体思路一致,都是为了说明如何把一个Python学习笔记26:符合Python风格的对象所举例的二维向量扩展到多维,并且符合序列的特性。
我们的首要工作是创建一个多维向量,并且实现之前二维向量的大多数基本功能。
import array
class VectorN():
typeCode = 'd'
def __init__(self, iterable):
self.__contents = array.array(self.typeCode, iterable)
def __iter__(self):
return iter(self.contents)
def __repr__(self):
cls = type(self)
clsName = cls.__name__
if len(self.contents) == 0:
return "{}()".format(clsName)
string = str(self.contents)
numbersStr = string[string.find('[')+1:-2]
return "{}({})".format(clsName, numbersStr)
def __str__(self):
if len(self.contents) == 0:
return "()"
string = str(self.contents)
numbersStr = string[string.find('[')+1:-2]
return "({})".format(numbersStr)
def __eq__(self, other):
return tuple(self) == tuple(other)
def __abs__(self):
pass
def __bool__(self):
pass
def __bytes__(self):
return self.typeCode.encode('UTF-8')+bytes(self.contents)
def angle(self):
pass
def __format__(self, format_spec):
pass
@classmethod
def fromBytes(cls, bytesVectorN):
typeCode = chr(bytesVectorN[0])
arrayVectorN = array.array(typeCode)
arrayVectorN.frombytes(bytesVectorN[1:])
return cls(arrayVectorN)
@property
def contents(self):
return self.__contents
def __hash__(self):
pass
这里基本是对照着之前我们创建的Vector
类进行创建的,部分比较棘手的方法先使用pass
进行占位,稍后我们重点讨论如何实现。
这里有这么几点需要着重说明:
建议对照Python学习笔记26:符合Python风格的对象中文末的
Vector
类完整代码进行理解。
array
来实现底层存储,相应的,接收参数也改为一个可迭代对象。__iter__
方法我们可以直接利用array
的迭代器直接返回,关于迭代器的详细内容我们将在以后进行讨论。__repr__
和__str__
我们都利用array
的字符串形式进行裁切后组合成我们需要的形式,这里其实可以更简单地将其转化为元组后直接使用元组地字符串形式,但多一步转化也意味着多一步性能浪费,对于多维数组来说,其多余的空间开销也的确值得注意,所以这里使用了字符串裁切的方式。__eq__
这里沿用Vector
中的做法也存在额外的性能浪费,这个我们在稍后将详细说明,并且会给出优化方案。__bytes__
和fromBytes
几乎和Vector
中的没有区别,只不过在反字节序列化的时候,return cls(arrayVectorN)
没有使用*
,这是因为我们在第一点中所说的,现在初始化方法只接收一个可迭代对象。property
装饰器的目的和之前一样,是为了后续实现散列。现在我们简单测试一下:
from vectorN import VectorN
l = [i for i in range(1, 11)]
vectorN = VectorN(l)
print(vectorN)
print(repr((vectorN)))
vectorN2 = VectorN(l)
print(vectorN == vectorN2)
vectorN3 = VectorN([1, 2, 3])
print(vectorN == vectorN3)
print(bytes(vectorN))
vectorNBytes = bytes(vectorN)
vectorN4 = VectorN.fromBytes(vectorNBytes)
print(vectorN == vectorN4)
# (1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0)
# VectorN(1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0)
# True
# False
# b'd\x00\x00\x00\x00\x00\x00\xf0?\x00\x00\x00\x00\x00\x00\x00@\x00\x00\x00\x00\x00\x00\x08@\x00\x00\x00\x00\x00\x00\x10@\x00\x00\x00\x00\x00\x00\x14@\x00\x00\x00\x00\x00\x00\x18@\x00\x00\x00\x00\x00\x00\x1c@\x00\x00\x00\x00\x00\x00 @\x00\x00\x00\x00\x00\x00"@\x00\x00\x00\x00\x00\x00$@'
# True
这里还有一个细节需要优化,因为我们这里是多维向量,所以如果包含的维度很大,通过__str__
和__repr__
返回的字符串对控制台显示就很不友好,事实上就像我们之前所说的,__repr__
只是用于开发者调试的,完全没有必要显示所有信息,我们这里可以学习Python官方组件在类似情况下的输出,对于多余信息用...
来简化显示。
def __repr__(self):
cls = type(self)
clsName = cls.__name__
if len(self.contents) == 0:
return "{}()".format(clsName)
string = reprlib.repr(self.contents)
numbersStr = string[string.find('[')+1:-2]
return "{}({})".format(clsName, numbersStr)
这里只要使用reprlib
模块就可以简单实现。
我们看效果:
from vectorN import VectorN
l = [i for i in range(1, 11)]
vectorN = VectorN(l)
print(repr(vectorN))
# VectorN(1.0, 2.0, 3.0, 4.0, 5.0, ...)
正如我们之前说的,类文件对象是要实现上下文协议,而类序列对象自然也要实现对应的协议。
这里的协议很像是传统编程语言中的接口,在Java中,实现了相应接口自然也可以将对象应用到所有使用该接口的用途中,而Python中的协议并不完全是类似接口的存在,其关键因素是Python中的协议仅代表一种约定,并不具有强制性。
我们用序列的实现协议进行类比,我们先来看如何将VectorN
“变成”一个序列:
def __getitem__(self, index):
return self.contents[index]
def __len__(self):
return len(self.contents)
很简单对不对,只要实现__getitem__
和__len__
就可以了,而且我们还可以通过委托给内含的array
来完成具体工作。
进行一下简单测试:
from vectorN import VectorN
l = [i for i in range(1, 11)]
vectorN = VectorN(l)
print(vectorN[0])
print(vectorN[2])
print(len(vectorN))
# 1.0
# 3.0
# 10
事实上,并不是每一个需要“变现地像个序列”的类序列对象都要实现__getitem__
和__len__
,如果你只会用到len(obj)
,则只实现__len__
是可行的。相似的,如果你只用切片,那不实现__len__
也可以。
所以说Python中的协议并不具有强制性,它只是指出完整协议需要实现哪些方法,而具体使用中你完全可以按照实际需求仅实现其中的一部分,这是符合Python风格的,Python的设计本身就处处体现着实用性的思想。
我们再说回VectorN
,看似其表现的像个序列,但是如果我们使用更多的切片功能:
from vectorN import VectorN
l = [i for i in range(1, 11)]
vectorN = VectorN(l)
print(vectorN[:])
print(vectorN[2:-1])
# array('d', [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0])
# array('d', [3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0])
看出问题了么?
切片得到的子序列并不是VectorN
类型,而是array
,这显然不是我们想要的。如果子序列和原始序列不是同一类型,那我们就不能针对子序列进行原始序列的操作,这很不Python。
所以我们接下来讨论如何进一步改造以实现完整的切片支持。
我们先来探索一下Python是如何实现切片的。
为了观察程序运行时Python解释器给__getitem__
传入的实际参数,我们对VectorN
做如下修改:
def __getitem__(self, index):
return index
运行测试程序:
from vectorN import VectorN
l = [i for i in range(1, 11)]
vectorN = VectorN(l)
print(vectorN[1])
print(vectorN[:])
print(vectorN[1:5])
print(vectorN[1:10:2])
print(vectorN[1:5, 2])
# 1
# slice(None, None, None)
# slice(1, 5, None)
# slice(1, 10, 2)
# (slice(1, 5, None), 2)
不难观察到以下规律:
index
就是单数字索引值。[:]
的时候,index
参数是一个slice
对象,具体是slice(None,None,None)
。[1:5]
的时候,参数是slice(1,5,None)
。NumPy
中的那种多维切片[1:5],2
的时候,index
参数是一个包含slice
对象的元组。现在我们还需要知道如何从slice
对象中提取start\stop\step
。
from vectorN import VectorN
l = [i for i in range(1, 11)]
vectorN = VectorN(l)
sli = slice(1,10,2)
print(dir(sli))
print(sli)
print(sli.start, sli.stop, sli.step)
sli2 = slice(1, None, None)
print(sli2.start, sli2.stop, sli2.step)
# ['__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__',
# '__init__', '__init_subclass__', '__le__', '__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'indices', 'start', 'step', 'stop']
# slice(1, 10, 2)
# 1 10 2
# 1 None None
通过一些简单试探,我们可以知道如何提取start\stop\step
,理论上我们现在就可以改造__getitem__
方法了,只不过麻烦一些,需要考虑这些值为None
的情况要如何处理。
事实上情况远比这要复杂,因为Python的切片操作是支持反向的,也就是说你还要考虑step
为负数,或者start
和stop
为负数的情况。
这无疑是让人抓狂的,为了处理这些Python已经干的很好的问题自己去再实现一套逻辑?
那显然是个糟糕的提议。
事实上,slice
实例有一个方法indices
,正可以解决这个问题:
from vectorN import VectorN
l = [i for i in range(1, 11)]
vectorN = VectorN(l)
sli = slice(1,10,2)
print(help(sli.indices))
# Help on built-in function indices:
# indices(...) method of builtins.slice instance
# S.indices(len) -> (start, stop, stride)
# Assuming a sequence of length len, calculate the start and stop
# indices, and the stride length of the extended slice described by
# S. Out of bounds indices are clipped in a manner consistent with the
# handling of normal slices.
# None
只要传入序列长度,indeces
方法就会自动计算出实际的相应起始、终止索引和步进,完全不需要我们自行计算。
索引的问题解决了,我们还需要考虑非法输入的问题,比如像上面那样多维切片,我们要如何处理,该不该报异常,该使用何种异常,提示信息又要写什么。
这个问题我们完全可以参考Python官方:
from vectorN import VectorN
l = [i for i in range(1, 11)]
vectorN = VectorN(l)
l[:,2]
# Traceback (most recent call last):
# File "D:\workspace\python\test\test.py", line 4, in
# l[:,2]
# TypeError: list indices must be integers or slices, not tuple
到了愉快的抄答案时间了。
好了,现在完事具备,我们来改写__getitem__
:
def __getitem__(self, index):
cls = type(self)
if isinstance(index, numbers.Integral):
return self.contents[index]
elif isinstance(index, slice):
start,stop,step = index.indices(len(self))
subArray = self.contents[start:stop:step]
return cls(subArray)
elif isinstance(index, tuple):
raise TypeError("list indices must be integers or slices, not tuple")
else:
raise TypeError("list indices must be integers or slices")
测试一下:
from vectorN import VectorN
l = [i for i in range(1, 11)]
vectorN = VectorN(l)
print(vectorN[1])
print(vectorN[:])
print(vectorN[1:5])
print(vectorN[1:10:2])
print(vectorN[1:5, 2])
# 2.0
# (1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0)
# (2.0, 3.0, 4.0, 5.0)
# (2.0, 4.0, 6.0, 8.0, 10.0)
# Traceback (most recent call last):
# File "D:\workspace\python\test\test.py", line 8, in
# print(vectorN[1:5, 2])
# File "D:\workspace\python\test\vectorN.py", line 71, in __getitem__
# raise TypeError("list indices must be integers or slices, not tuple")
# TypeError: list indices must be integers or slices, not tuple
事实上,在调用array
切片的时候,我们可以不使用start:stop:step
的方式,可以直接使用slice
实例,array
可以正确进行处理:
elif isinstance(index, slice):
# start,stop,step = index.indices(len(self))
# subArray = self.contents[start:stop:step]
subArray = self.contents[index]
return cls(subArray)
但之前我们做的探索并非无用功,比如我们底层如果是自己实现,而非是利用已有容器,那就很有必要获取正确的索引和步进,此外我们也对slice
实例有了更多的了解不是吗。
在之前的Vector
类中,我们通过使用装饰器property
实现了对私有属性的读取和保护,那在VectorN
中,如果我们需要以vectorN.x\vectorN.y
等方式读取前几个元素是不是也可以用类似方法?
答案当然是可以的,但是对两三个元素我们可以如此处理,如果是多个元素也要一一创建方法并用property
装饰?
当然不用,Python提供一个魔术方法__getattr__
正是用于处理此类问题。
注意,Python中还有一个
__getattribute__
方法,这两个方法效果完全不同,不要搞混。
__getattr__
在实现__getattr__
之前,我们还要搞清楚如果访问的属性超过合理范围,需要怎么显示错误。
当然是继续抄官方了:
from vectorN import VectorN
l = [i for i in range(1, 11)]
vectorN = VectorN(l)
l[13]
# Traceback (most recent call last):
# File "D:\workspace\python\test\test.py", line 4, in
# l[13]
# IndexError: list index out of range
现在来实现__getattr__
:
def __getattr__(self, name):
attrStr = "xyzt"
if len(name) == 1:
index = attrStr.find(name)
if 0 <= index < len(self):
return self.contents[index]
raise IndexError("list index out of range")
测试一下:
from vectorN import VectorN
l = [i for i in range(1, 11)]
vectorN = VectorN(l)
print(vectorN.x)
print(vectorN.y)
print(vectorN.z)
# 1.0
# 2.0
# 3.0
__getattr__
的运行机制是:当Python解释器试图获取一个实例属性,但是实例字典中没有的时候,会在其类中查找类属性,如果类属性也没有,就会在父类中查找,如果父类中也没有,就会通过__getattr__
函数获取。
真实情况比这更复杂,会在以后进行讨论。
这种属性访问方式目前看来似乎没有问题,但是我们一旦进行赋值操作:
from vectorN import VectorN
l = [i for i in range(1, 11)]
vectorN = VectorN(l)
print(vectorN.x)
vectorN.x = 2
print(vectorN.x)
print(vectorN[0])
# 1.0
# 2
# 1.0
当我们试图进行赋值操作的时候,奇怪的事情发生了。
事实上只要我们对实际上并不存在的属性进行赋值,就会给实例添加一个新的属性,这就会导致我们原来设置的__getattr__
机制完全失效,后续的读取和赋值操作都只会针对新产生的实例属性。
要解决这个问题我们就要实现__setattr__
。
__setattr__
事实上__setattr__
和__getattr__
经常成对出现,如果只设置了其中之一,很可能会出现一些意料之外的bug。
def __setattr__(self, name, value):
cls = type(self)
if len(name) == 1:
msg = ""
if name in cls.attrStr:
msg = "readonly attribute {}".format(name)
else:
pass
raise AttributeError(msg)
super().__setattr__(name, value)
因为
__setattr__
也要使用attrStr
,所以改为类属性,这里不多做演示。
用同样的测试程序进行测试:
from vectorN import VectorN
l = [i for i in range(1, 11)]
vectorN = VectorN(l)
print(vectorN.x)
vectorN.x = 2
print(vectorN.x)
print(vectorN[0])
# 1.0
# Traceback (most recent call last):
# File "D:\workspace\python\test\test.py", line 5, in
# vectorN.x = 2
# File "D:\workspace\python\test\vectorN.py", line 97, in __setattr__
# raise AttributeError(msg)
# AttributeError: readonly attribute x
我们在Vector
中使用位运算^
来实现哈希算法,相应的,这里我们同样可以通过累积异或来实现多维向量的哈希算法。
这里的哈希算法就是散列算法,一个意思,因为"hash"更贴近音译,所以我更习惯用哈希称呼。
提到累积运算,高阶函数reduce
当然会是首先想到的。
def __hash__(self): hashes = [hash(num) for num in self.contents] return functools.reduce(operator.xor, hashes, 0)
这里需要注意的是,reduce
的第三个参数是0
,这是为了避免第二个参数为空或者仅有一个元素时候可能出现的bug。但这个参数需要注意的是并不能无脑设置为0,这个参数是和第一个参数的具体运算方式密切相关的。简单来说它要符合相应运算的幂等性。如果运算是+
,则是0,因为0无论加多少次还是0。如果运算是*
,则是1,因为1无论被乘以多少次还是1。对于异或,则是0,因为异或操作的准则为“相同为0,相异为1”,所以0被异或多少次依然为0。
from vectorN import VectorN
l = [i for i in range(1, 11)]
vectorN = VectorN(l)
print(hash(vectorN))
vectorN2 = VectorN([1,2,3])
print(hash(vectorN2))
vectorSet = set()
vectorSet.add(vectorN)
vectorSet.add(vectorN2)
# 11
# 0
通过上面的测试我们可以知道,VectorN
已经被我们成功散列化,它是可散列的了。
我们在之前提到过,在实现==
运算符重载的时候存在性能浪费,我们这里进行优化。
如果是用其它传统变成语言的方式,我会这么优化:
def __eq__(self, other):
if len(self) != len(other):
return False
else:
for i in range(len(self)):
if self[i] != other[i]:
return False
return True
测试一下:
from vectorN import VectorN
l = [i for i in range(1, 11)]
vectorN = VectorN(l)
vectorN2 = VectorN([1,2,3])
print(vectorN == vectorN2)
vectorN3 = VectorN(l)
print(vectorN == vectorN3)
OK,没有任何问题。但是吹毛求疵的人会说这很不Python。
我们看一下用Python的方式要怎么实现。
这里我们需要用到一个Python内建函数zip
。
zip
是为了解决如何同时遍历多个可迭代对象的问题:
a = [i for i in range(10)]
b = [i for i in range(1, 9)]
for num1, num2 in zip(a, b):
print(num1, num2)
# 0 1
# 1 2
# 2 3
# 3 4
# 4 5
# 5 6
# 6 7
# 7 8
我们需要注意到的是,zip
在处理多个可迭代对象的时候,如果这些可迭代对象包含的元素个数并不相同,则会在遍历完最少元素的可迭代对象后立即结束遍历,不会有任何异常或者报错,就像示例中一样,即使a
还有两个元素没有输出,遍历也结束了。
相应的,还有一个zip_longest
:
from itertools import zip_longest
a = [i for i in range(10)]
b = [i for i in range(1, 9)]
for num1, num2 in zip_longest(a, b, fillvalue=-1):
print(num1, num2)
# 0 1
# 1 2
# 2 3
# 3 4
# 4 5
# 5 6
# 6 7
# 7 8
# 8 -1
# 9 -1
zip_longest
需要导入itertools
模块,且使用的时候需要指定一个填充值fillvalue
。
当有可迭代对象遍历完,但其他对象还没有的时候,缺少的相应元素就会使用填充值进行填充,就像示例中的-1
那样。
我们现在用zip
来改写:
def __eq__(self, other):
if len(self) != len(other):
return False
else:
for num1, num2 in zip(self, other):
if num1 != num2:
return False
return True
看似没有改变多少,但其实我们已经摒弃了实际下标,而是使用遍历器同时遍历两个容器,这已经是相当大的进步。即使两个容器下标不同,但只要有相同的元素个数,以及元素能一一对应相等,就可以认为是相等的两个容器。这无疑比使用下标更为灵活。
我们可以进一步Python化:
def __eq__(self, other):
if len(self) != len(other):
return False
else:
return all(num1 == num2 for num1,num2 in zip(self, other))
all
函数的参考可以看这里。
甚至是这样:
def __eq__(self, other):
return len(self) != len(other) and all(num1 == num2 for num1,num2 in zip(self, other))
这无疑比我们一开始的写法更Python,更“政治正确”。
但在我看来,如果你时间充裕,而且对编写更Python化的代码更痴迷,你完全可以追求此类的写法,但如果你时间紧迫,且对离散数学很头大,那完全可以跳过此类的写法,毕竟,Python的真正奥义是实用。
终于到我们最后一个议题了。
在Vector
中我们使用格式化进行极坐标输出,对应到多维向量,则是“球面坐标”或“超球面坐标”。
球面坐标的解释可以看这里。
要实现到球面坐标系的转化,我们要实现对n维向量的极坐标系计算。具体数学公式我就不细究了,老实说,我现在还能看懂极坐标就已经很难为自己了orz,所以我这里照抄《Fluent Python》中的代码。
这里先需要实现多维向量的求模:
def __abs__(self):
return math.sqrt(sum(x*x for x in self))
再实现求坐标转换算法:
def angle(self, n):
r = math.sqrt(sum(x*x for x in self[n:]))
a = math.atan2(r, self[n-1])
if (n == len(self)-1) and (self[-1]<0):
return math.pi * 2 -a
else:
return a
def angles(self):
return (self.angle(n) for n in range(1, len(self)))
最后实现格式化:
def __format__(self, format_spec):
if format_spec.endswith('h'):
format_spec = format_spec[:-1]
coords = itertools.chain([abs(self)],self.angles())
outer_fmt = "<{}>"
else:
coords = self
outer_fmt = "({})"
components = (format(c, format_spec) for c in coords)
return outer_fmt.format(','.join(components))
测试一下:
from vectorN import VectorN
l = [i for i in range(1, 11)]
vectorN = VectorN(l)
print(format(vectorN,'.2fh'))
# <19.62,1.52,1.47,1.42,1.36,1.30,1.23,1.15,1.03,0.84>
老实说我也不知道结果是否正确=。=,就当是正确的好了。
好了,以上就是这次的全部内容,能看到这里的童鞋值得鼓励。
最后附上目前为止VectorN
的完整定义,便于查看:
import array
import numbers
import functools
import operator
import math
import itertools
import reprlib
class VectorN():
typeCode = 'd'
attrStr = "xyzt"
def __init__(self, iterable):
self.__contents = array.array(self.typeCode, iterable)
def __iter__(self):
return iter(self.contents)
def __repr__(self):
cls = type(self)
clsName = cls.__name__
if len(self.contents) == 0:
return "{}()".format(clsName)
string = reprlib.repr(self.contents)
numbersStr = string[string.find('[')+1:-2]
return "{}({})".format(clsName, numbersStr)
def __str__(self):
if len(self.contents) == 0:
return "()"
string = str(self.contents)
numbersStr = string[string.find('[')+1:-2]
return "({})".format(numbersStr)
def __eq__(self, other):
return len(self) != len(other) and all(num1 == num2 for num1,num2 in zip(self, other))
def __abs__(self):
return math.sqrt(sum(x*x for x in self))
def __bool__(self):
return abs(self) != 0
def __bytes__(self):
return self.typeCode.encode('UTF-8')+bytes(self.contents)
@classmethod
def fromBytes(cls, bytesVectorN):
typeCode = chr(bytesVectorN[0])
arrayVectorN = array.array(typeCode)
arrayVectorN.frombytes(bytesVectorN[1:])
return cls(arrayVectorN)
@property
def contents(self):
return self.__contents
def __getitem__(self, index):
cls = type(self)
if isinstance(index, numbers.Integral):
return self.contents[index]
elif isinstance(index, slice):
# start,stop,step = index.indices(len(self))
# subArray = self.contents[start:stop:step]
subArray = self.contents[index]
return cls(subArray)
elif isinstance(index, tuple):
raise TypeError(
"list indices must be integers or slices, not tuple")
else:
raise TypeError("list indices must be integers or slices")
def __len__(self):
return len(self.contents)
def __getattr__(self, name):
cls = type(self)
if len(name) == 1:
index = cls.attrStr.find(name)
if 0 <= index < len(self):
return self.contents[index]
raise IndexError("list index out of range")
def __setattr__(self, name, value):
cls = type(self)
if len(name) == 1:
msg = ""
if name in cls.attrStr:
msg = "readonly attribute {}".format(name)
else:
pass
raise AttributeError(msg)
super().__setattr__(name, value)
def __hash__(self):
hashes = [hash(num) for num in self.contents]
return functools.reduce(operator.xor, hashes, 0)
def angle(self, n):
r = math.sqrt(sum(x*x for x in self[n:]))
a = math.atan2(r, self[n-1])
if (n == len(self)-1) and (self[-1]<0):
return math.pi * 2 -a
else:
return a
def angles(self):
return (self.angle(n) for n in range(1, len(self)))
def __format__(self, format_spec):
if format_spec.endswith('h'):
format_spec = format_spec[:-1]
coords = itertools.chain([abs(self)],self.angles())
outer_fmt = "<{}>"
else:
coords = self
outer_fmt = "({})"
components = (format(c, format_spec) for c in coords)
return outer_fmt.format(','.join(components))
又是在一个没有暖气的阴冷北方下午完成了这篇博客,你们对我的CSDN博客的关注评论和点赞是我继续更新的最大动力,在这里再次感谢。
对我的博客有任何看法和内容纠错,都可以在下面留言。
谢谢阅读。