在Python中实现2D切片

4 投票
1 回答
657 浏览
提问于 2025-04-17 20:18

我正在用Python实现一个线性代数库(我知道可能已经有现成的库,但我这样做是为了学习Python和我考试需要的数学知识),我想能够像这样访问矩阵的元素或子集:

(我的矩阵类是元组的一个子类。)

  • M = Matrix([元素的行列表])
  • M[1, 2] 获取位置(1, 2)的元素
  • M[3] 获取第3行

这些操作都比较简单,但我还想实现切片功能,像这样:

  • M[:,:] 返回整个矩阵
  • M[1:6:2] 返回第1、3和5行
  • M[1:6:2, 0:2] 返回一个由第1、3和5行与前两列交叉组成的矩阵。

我已经实现了这些功能,但我觉得我的代码看起来不够“Python风格”:

def __getitem__ (self, idx):
    if isinstance(idx, numbers.Integral):
        # Code to return the row at idx
    elif (isinstance(idx, tuple) and len(idx) == 2 and
            all(isinstance(i, numbers.Integral) for i in idx)):
        # Code to return element at idx
    elif (isinstance(idx, tuple) and len(idx) == 2 and
            all(isinstance(i, slice) for i in idx)):
        # Code to parse slices

另一个问题是,两个索引必须是数字或切片,我不能混合使用。如果要这样做,就需要再加两个elif块,这样看起来就太多了。现在代码已经很丑了。

我觉得答案可能涉及到鸭子类型,但我不太确定怎么实现。我一直在看try:except:块,但不确定怎么把它们串联起来,而且我也不想嵌套太多。

所以,非常感谢你的阅读。实现这样的功能最好的方法是什么呢?

1 个回答

4

你基本上需要做一些类似这样的事情……不过至少你可以减少一些重复的代码。

首先,考虑 [1,] 表示“第1行”,就像 [1] 一样,这样想是合理的。(numpy 也是这么做的。)这意味着你不需要区分元组和整数,只需把整数当作一个包含1个元素的元组来处理。换句话说:

def __getitem__(self, idx):
    if isinstance(idx, numbers.Integral):
        idx = (idx, slice(None, None, None))
    # now the rest of your code only needs to handle tuples

其次,虽然你的示例代码只处理了两个切片的情况,但你的实际代码必须处理两种切片、一个切片和一个整数、一个整数和一个切片、两个整数、一个切片或一个整数。如果你能把处理切片的代码提取出来,就不需要重复写很多次了。

处理整数和切片的一个技巧是把 [n] 看作一个包装器,实际上它做的事情是 [n:n+1][0],这样可以进一步简化代码。(这比想象中稍微复杂一点,因为你需要特别处理负数,或者只处理 -1,因为显然 n[-1] != n[-1:0][0]。)对于一维数组来说,这可能不太值得,但对于二维数组来说,这样做可能更有意义,因为这样在处理列的时候,你总是有一组行,而不仅仅是一行。

另一方面,你可能想在 __getitem____setitem__ 之间共享一些代码……这会让一些技巧变得不可能或更难实现。所以,这里有一个权衡。

无论如何,这里有一个例子,展示了我能想到的所有简化和前后处理(可能比你想要的还要多),这样最终你总是查找一对切片:

class Matrix(object):
    def __init__(self):
        self.m = [[row + col/10. for col in range(4)] for row in range(4)]
    def __getitem__(self, idx):
        if isinstance(idx, (numbers.Integral, slice)):
            idx = (idx, slice(None, None, None))
        elif len(idx) == 1:
            idx = (idx[0], slice(None, None, None))
        rowidx, colidx = idx
        rowslice, colslice = True, True
        if isinstance(rowidx, numbers.Integral):
            rowidx, rowslice = slice(rowidx, rowidx+1), False
        if isinstance(colidx, numbers.Integral):
            colidx, colslice = slice(colidx, colidx+1), False
        ret = self.m[rowidx][colidx]
        if not colslice:
            ret = [row[0] for row in ret]
        if not rowslice:
            ret = ret[0]
        return ret

或者,如果你沿着另一个方向重构代码可能会更好:先获取行,然后在其中获取列:

def _getrow(self, idx):
    return self.m[idx]

def __getitem__(self, idx):
    if isinstance(idx, (numbers.Integral, slice)):
        return self._getrow(idx)
    rowidx, colidx = idx
    if isinstance(rowidx, numbers.Integral):
        return self._getrow(rowidx)[colidx]
    else:
        return [row[colidx] for row in self._getrow(rowidx)]

这看起来简单多了,但我在这里有点偷懒,把第二个索引转发给了普通的 list,这只因为我的底层存储是一个包含 listlist。不过如果你有任何可以索引的行对象可以使用(并且创建这些对象不会浪费过多的时间或空间),你也可以使用同样的技巧。


如果你反对在索引参数上进行类型转换,是的,这确实看起来不太符合 Python 的风格,但不幸的是,这就是 __getitem__ 通常的工作方式。如果你想使用常见的 EAFTP try 逻辑,可以这样做,但我觉得在多个地方尝试两种不同的 API(例如,[0] 用于元组,.start 用于切片)并不会让代码更易读。你最终会在顶部做“鸭子类型切换”,像这样:

try:
    idx[0]
except AttributeError:
    idx = (idx, slice(None, None, None))

……如此等等,这样的代码量是正常类型切换的两倍,没有任何通常的好处。

撰写回答