Наследование от ndarray
Введение
Наследование от ndarray относительно просто, но оно имеет некоторые сложности по сравнению с другими объектами Python. На этой странице мы объясним механизмы, которые позволяют вам наследовать от ndarray, и последствия для реализации подкласса.
ndarray и создание объектов
Наследование от ndarray усложняется тем, что новые экземпляры классов ndarray могут быть созданы тремя различными способами. Это:
- Явный вызов конструктора — как в
MySubClass(params). Это обычный способ создания экземпляров в Python. - Преобразование представления — преобразование существующего ndarray в заданный подкласс.
- Создание из шаблона — создание нового экземпляра из экземпляра-шаблона. Примеры включают возвращение срезов из подклассированного массива, создание типов возвращаемых значений из функций ufunc и копирование массивов. Подробнее см. Создание нового из шаблона
Два последних пункта являются особенностями ndarray — для поддержки таких функций, как взятие срезов массива. Сложности при наследовании от ndarray обусловлены механизмами NumPy для поддержки этих двух способов создания экземпляров.
Преобразование представления
Преобразование представления — стандартный механизм ndarray, позволяющий взять ndarray любого подкласса и вернуть представление массива как другой (указанный) подкласс:
>>> import numpy as np >>> # create a completely useless ndarray subclass >>> class C(np.ndarray): pass >>> # create a standard ndarray >>> arr = np.zeros((3,)) >>> # take a view of it, as our useless subclass >>> c_arr = arr.view(C) >>> type(c_arr) <class 'C'>
Создание нового из шаблона
Новые экземпляры подкласса ndarray также могут быть созданы механизмом, очень похожим на преобразование представления, когда NumPy обнаруживает необходимость создания нового экземпляра из экземпляра-шаблона. Самый очевидный случай — когда вы берете срезы подклассированных массивов. Например:
>>> v = c_arr[1:] >>> type(v) # the view is of type 'C' <class 'C'> >>> v is c_arr # but it's a new instance False
Срез представляет собой представление исходных c_arr данных. Поэтому, когда мы получаем представление от ndarray, мы возвращаем новый ndarray того же класса, указывающий на данные в оригинале.
Существуют и другие ситуации использования ndarray, где такие представления необходимы, например, при копировании массивов (c_arr.copy()), создании массивов вывода функций ufunc (см. также __array_wrap__ для функций ufunc и других функций) и методах сокращения (например, c_arr.mean()).
Связь преобразования представления и создания нового из шаблона
Эти пути используют одни и те же механизмы. Различие здесь проводится потому, что они приводят к разным входным данным для ваших методов. В частности, преобразование представления означает, что вы создали новый экземпляр вашего типа массива из любого потенциального подкласса ndarray. Создание нового из шаблона означает, что вы создали новый экземпляр вашего класса из существующего экземпляра, позволяя вам, например, скопировать атрибуты, специфичные для вашего подкласса.
Последствия наследования
Если мы наследуем от ndarray, нам нужно иметь дело не только с явным созданием нашего типа массива, но и с преобразованием представления или созданием нового из шаблона. NumPy имеет механизмы для этого, и эти механизмы делают наследование несколько нестандартным.
Есть два аспекта механизма, используемого ndarray для поддержки представлений и создания новых экземпляров из шаблона в подклассах.
Во-первых, использование метода ndarray.__new__ для основной работы по инициализации объектов вместо более обычного метода __init__. Во-вторых, использование метода __array_finalize__ для обработки подклассами последствий создания представлений и новых экземпляров из шаблонов.
Краткое руководство по Python для __new__ и __init__
__new__ — стандартный метод Python, и, если он присутствует, вызывается перед __init__ при создании экземпляра класса. Для получения дополнительной информации см. документацию Python по __new__.
Например, рассмотрим следующий код Python:
class C:
def __new__(cls, *args):
print('Cls in __new__:', cls)
print('Args in __new__:', args)
# The `object` type __new__ method takes a single argument.
return object.__new__(cls)
def __init__(self, *args):
print('type(self) in __init__:', type(self))
print('Args in __init__:', args)
что означает:
>>> c = C('hello')
Cls in __new__: <class 'C'>
Args in __new__: ('hello',)
type(self) in __init__: <class 'C'>
Args in __init__: ('hello',)
Когда мы вызываем C('hello'), метод __new__ получает в качестве первого аргумента свой класс и переданный аргумент, который является строкой 'hello'. После того, как Python вызовет __new__, он обычно (см. ниже) вызывает наш метод __init__, с результатом вызова __new__ в качестве первого аргумента (теперь это экземпляр класса), а затем следующие переданные аргументы.
Как видите, объект может быть инициализирован в методе __new__ или методе __init__, или в обоих, и на самом деле ndarray не имеет метода __init__, потому что вся инициализация выполняется в методе __new__.
Почему используется __new__ вместо обычного __init__? Потому что в некоторых случаях, как в ndarray, мы хотим иметь возможность возвращать объект другого класса. Рассмотрим следующий пример:
class D(C):
def __new__(cls, *args):
print('D cls is:', cls)
print('D args in __new__:', args)
return C.__new__(C, *args)
def __init__(self, *args):
# we never get here
print('In D __init__')
что означает:
>>> obj = D('hello')
D cls is: <class 'D'>
D args in __new__: ('hello',)
Cls in __new__: <class 'C'>
Args in __new__: ('hello',)
>>> type(obj)
<class 'C'>
Определение C такое же, как и раньше, но для D, метод __new__ возвращает экземпляр класса C вместо D. Обратите внимание, что метод __init__ класса D не вызывается. В общем случае, когда метод __new__ возвращает объект класса, отличного от класса, в котором он определен, метод __init__ этого класса не вызывается.
Вот как подклассы класса ndarray могут возвращать представления, сохраняя тип класса. При получении представления стандартный механизм ndarray создает новый объект ndarray с чем-то вроде:
obj = ndarray.__new__(subtype, shape, ...
где subdtype — подкласс. Таким образом, возвращаемое представление имеет тот же класс, что и подкласс, а не класс ndarray.
Это решает проблему возвращения представлений того же типа, но теперь у нас есть новая проблема. Механизм ndarray может установить класс таким образом в своих стандартных методах для получения представлений, но метод ndarray __new__ ничего не знает о том, что мы сделали в нашем методе __new__ для установки атрибутов и т. д. (Отступление — почему бы не вызвать obj = subdtype.__new__(...? Потому что у нас может не быть метода __new__ с тем же сигнатуром вызова).
Роль __array_finalize__
__array_finalize__ — механизм, предоставляемый NumPy для обработки подклассами различных способов создания новых экземпляров.
Помните, что экземпляры подклассов могут быть созданы тремя способами:
- Явный вызов конструктора (
obj = MySubClass(params)). Это вызовет обычную последовательностьMySubClass.__new__и, если она существует,MySubClass.__init__. - Преобразование представления
- Создание нового из шаблона
Наш метод MySubClass.__new__ вызывается только в случае явного вызова конструктора, поэтому мы не можем полагаться на MySubClass.__new__ или MySubClass.__init__ для обработки преобразования представления и создания нового из шаблона. Оказывается, что метод MySubClass.__array_finalize__ вызывается для всех трёх способов создания объектов, поэтому здесь обычно происходит обработка создания объектов.
- Для явного вызова конструктора наш подкласс должен создать новый экземпляр ndarray своего класса. На практике это означает, что нам, авторам кода, нужно вызвать
ndarray.__new__(MySubClass,...), вызов из иерархии классов кsuper(MySubClass, cls).__new__(cls, ...), или выполнить преобразование представления существующего массива (см. ниже). - Для преобразования представления и создания нового из шаблона вызывается аналог
ndarray.__new__(MySubClass,...на уровне C.
Аргументы, которые получает __array_finalize__, различаются для трёх способов создания экземпляров, описанных выше.
Следующий код позволяет просмотреть последовательности вызовов и аргументы:
import numpy as np
class C(np.ndarray):
def __new__(cls, *args, **kwargs):
print('In __new__ with class %s' % cls)
return super(C, cls).__new__(cls, *args, **kwargs)
def __init__(self, *args, **kwargs):
# in practice you probably will not need or want an __init__
# method for your subclass
print('In __init__ with class %s' % self.__class__)
def __array_finalize__(self, obj):
print('In array_finalize:')
print(' self type is %s' % type(self))
print(' obj type is %s' % type(obj))
Теперь:
>>> # Explicit constructor >>> c = C((10,)) In __new__ with class <class 'C'> In array_finalize: self type is <class 'C'> obj type is <type 'NoneType'> In __init__ with class <class 'C'> >>> # View casting >>> a = np.arange(10) >>> cast_a = a.view(C) In array_finalize: self type is <class 'C'> obj type is <type 'numpy.ndarray'> >>> # Slicing (example of new-from-template) >>> cv = c[:1] In array_finalize: self type is <class 'C'> obj type is <class 'C'>
Подпись __array_finalize__ выглядит так:
def __array_finalize__(self, obj):
Видно, что вызов super, который переходит в ndarray.__new__, передаёт __array_finalize__ новый объект нашего класса (self) и объект, из которого было получено представление (obj). Как видно из вывода выше, self всегда является вновь созданным экземпляром нашего подкласса, а тип obj отличается для трёх способов создания экземпляров:
- При вызове из явного конструктора
obj—None. - При вызове из преобразования представления
objможет быть экземпляром любого подкласса ndarray, включая наш собственный. - При вызове из создания нового из шаблона
objявляется другим экземпляром нашего подкласса, который мы можем использовать для обновления нового экземпляраself.
Так как __array_finalize__ — единственный метод, который всегда видит создание новых экземпляров, это разумное место для заполнения значений по умолчанию атрибутов новых объектов, среди прочих задач.
Это может быть понятнее на примере.
Простой пример — добавление дополнительного атрибута к ndarray
import numpy as np
class InfoArray(np.ndarray):
def __new__(subtype, shape, dtype=float, buffer=None, offset=0,
strides=None, order=None, info=None):
# Create the ndarray instance of our type, given the usual
# ndarray input arguments. This will call the standard
# ndarray constructor, but return an object of our type.
# It also triggers a call to InfoArray.__array_finalize__
obj = super(InfoArray, subtype).__new__(subtype, shape, dtype,
buffer, offset, strides,
order)
# set the new 'info' attribute to the value passed
obj.info = info
# Finally, we must return the newly created object:
return obj
def __array_finalize__(self, obj):
# ``self`` is a new object resulting from
# ndarray.__new__(InfoArray, ...), therefore it only has
# attributes that the ndarray.__new__ constructor gave it -
# i.e. those of a standard ndarray.
#
# We could have got to the ndarray.__new__ call in 3 ways:
# From an explicit constructor - e.g. InfoArray():
# obj is None
# (we're in the middle of the InfoArray.__new__
# constructor, and self.info will be set when we return to
# InfoArray.__new__)
if obj is None: return
# From view casting - e.g arr.view(InfoArray):
# obj is arr
# (type(obj) can be InfoArray)
# From new-from-template - e.g infoarr[:3]
# type(obj) is InfoArray
#
# Note that it is here, rather than in the __new__ method,
# that we set the default value for 'info', because this
# method sees all creation of default objects - with the
# InfoArray.__new__ constructor, but also with
# arr.view(InfoArray).
self.info = getattr(obj, 'info', None)
# We do not need to return anything
Использование объекта выглядит так:
>>> obj = InfoArray(shape=(3,)) # explicit constructor >>> type(obj) <class 'InfoArray'> >>> obj.info is None True >>> obj = InfoArray(shape=(3,), info='information') >>> obj.info 'information' >>> v = obj[1:] # new-from-template - here - slicing >>> type(v) <class 'InfoArray'> >>> v.info 'information' >>> arr = np.arange(10) >>> cast_arr = arr.view(InfoArray) # view casting >>> type(cast_arr) <class 'InfoArray'> >>> cast_arr.info is None True
Этот класс не очень полезен, так как он имеет тот же конструктор, что и базовый объект ndarray, включая передачу буферов, форм и так далее. Мы, скорее всего, предпочли бы, чтобы конструктор мог принимать уже сформированный ndarray из обычных вызовов NumPy к np.array и возвращать объект.
Более реалистичный пример — атрибут, добавленный к существующему массиву
Вот класс, который принимает стандартный уже существующий ndarray, преобразует его в наш тип и добавляет дополнительный атрибут.
import numpy as np
class RealisticInfoArray(np.ndarray):
def __new__(cls, input_array, info=None):
# Input array is an already formed ndarray instance
# We first cast to be our class type
obj = np.asarray(input_array).view(cls)
# add the new attribute to the created instance
obj.info = info
# Finally, we must return the newly created object:
return obj
def __array_finalize__(self, obj):
# see InfoArray.__array_finalize__ for comments
if obj is None: return
self.info = getattr(obj, 'info', None)
Таким образом:
>>> arr = np.arange(5) >>> obj = RealisticInfoArray(arr, info='information') >>> type(obj) <class 'RealisticInfoArray'> >>> obj.info 'information' >>> v = obj[1:] >>> type(v) <class 'RealisticInfoArray'> >>> v.info 'information'
__array_ufunc__ для функций ufunc
Введено в версии 1.13.
Подкласс может переопределить поведение при выполнении numpy ufuncs, переопределив метод по умолчанию ndarray.__array_ufunc__. Этот метод выполняется вместо ufunc и должен возвращать либо результат операции, либо NotImplemented, если запрошенная операция не реализована.
Подпись метода __array_ufunc__:
def __array_ufunc__(ufunc, method, *inputs, **kwargs): - *ufunc* is the ufunc object that was called. - *method* is a string indicating how the Ufunc was called, either ``"__call__"`` to indicate it was called directly, or one of its :ref:`methods<ufuncs.methods>`: ``"reduce"``, ``"accumulate"``, ``"reduceat"``, ``"outer"``, or ``"at"``. - *inputs* is a tuple of the input arguments to the ``ufunc`` - *kwargs* contains any optional or keyword arguments passed to the function. This includes any ``out`` arguments, which are always contained in a tuple.
Типичная реализация преобразует входные или выходные данные, являющиеся экземплярами собственного класса, передаёт всё суперклассу с помощью super(), а затем возвращает результаты после возможного обратного преобразования. Пример, взятый из тестового случая test_ufunc_override_with_super в core/tests/test_umath.py, следующий.
input numpy as np
class A(np.ndarray):
def __array_ufunc__(self, ufunc, method, *inputs, out=None, **kwargs):
args = []
in_no = []
for i, input_ in enumerate(inputs):
if isinstance(input_, A):
in_no.append(i)
args.append(input_.view(np.ndarray))
else:
args.append(input_)
outputs = out
out_no = []
if outputs:
out_args = []
for j, output in enumerate(outputs):
if isinstance(output, A):
out_no.append(j)
out_args.append(output.view(np.ndarray))
else:
out_args.append(output)
kwargs['out'] = tuple(out_args)
else:
outputs = (None,) * ufunc.nout
info = {}
if in_no:
info['inputs'] = in_no
if out_no:
info['outputs'] = out_no
results = super(A, self).__array_ufunc__(ufunc, method,
*args, **kwargs)
if results is NotImplemented:
return NotImplemented
if method == 'at':
if isinstance(inputs[0], A):
inputs[0].info = info
return
if ufunc.nout == 1:
results = (results,)
results = tuple((np.asarray(result).view(A)
if output is None else output)
for result, output in zip(results, outputs))
if results and isinstance(results[0], A):
results[0].info = info
return results[0] if len(results) == 1 else results
Таким образом, этот класс не выполняет ничего интересного: он просто преобразует все экземпляры своего класса в обычный ndarray (иначе мы получим бесконечную рекурсию!) и добавляет словарь info, который указывает, какие входные и выходные данные он преобразовал. Следовательно, например:
>>> a = np.arange(5.).view(A)
>>> b = np.sin(a)
>>> b.info
{'inputs': [0]}
>>> b = np.sin(np.arange(5.), out=(a,))
>>> b.info
{'outputs': [0]}
>>> a = np.arange(5.).view(A)
>>> b = np.ones(1).view(A)
>>> c = a + b
>>> c.info
{'inputs': [0, 1]}
>>> a += b
>>> a.info
{'inputs': [0, 1], 'outputs': [0]}
Обратите внимание, что другой подход заключается в использовании getattr(ufunc,
methods)(*inputs, **kwargs) вместо вызова super. В этом примере результат будет идентичным, но есть разница, если другой операнд также определяет __array_ufunc__. Например, предположим, что мы вычисляем np.add(a, b), где b является экземпляром другого класса B, который имеет переопределение. Если вы используете super как в примере, ndarray.__array_ufunc__ заметит, что b имеет переопределение, что означает, что он не может вычислить результат самостоятельно. Таким образом, он вернёт NotImplemented, и наш класс A тоже. Затем управление передаётся b, который либо знает, как с этим справиться и производит результат, либо нет и возвращает NotImplemented, вызывая TypeError.
Если вместо этого мы заменим наш вызов super на getattr(ufunc, method), мы фактически сделаем np.add(a.view(np.ndarray), b). Опять же, будет вызван B.__array_ufunc__, но теперь он видит ndarray как другой аргумент. Вероятно, он будет знать, как с этим справиться, и вернёт нам новый экземпляр класса B. Наш пример класса не настроен для обработки этого, но это может быть лучшим подходом, если, например, кто-то переиспользует MaskedArray с помощью __array_ufunc__.
Наконец, если маршрут super подходит для данного класса, преимущество его использования заключается в том, что он помогает в построении иерархий классов. Например, предположим, что наш другой класс B также использовал super в своей реализации __array_ufunc__, и мы создали класс C, который зависел от обоих, т.е., class C(A, B) (с, для простоты, без другого переопределения __array_ufunc__). Тогда любой ufunc над экземпляром C передастся A.__array_ufunc__, вызов super в A пойдёт в B.__array_ufunc__, а вызов super в B пойдёт в ndarray.__array_ufunc__, что позволит A и B сотрудничать.
__array_wrap__ для ufuncs и других функций
До numpy 1.13 поведение ufuncs можно было настроить только с помощью __array_wrap__ и __array_prepare__ . Эти два параметра позволяли изменять тип выходных данных ufunc, но, в отличие от __array_ufunc__, не позволяли вносить какие-либо изменения в входные данные. Надеемся в конечном итоге устареть их, но __array_wrap__ также используется другими функциями и методами numpy, такими как squeeze, поэтому в настоящее время он всё ещё необходим для полной функциональности.
По сути, __array_wrap__ «оборачивает действие», позволяя подклассу устанавливать тип возвращаемого значения и обновлять атрибуты и метаданные. Давайте покажем, как это работает на примере. Сначала мы вернёмся к более простому подклассу, но с другим именем и некоторыми операциями вывода:
import numpy as np
class MySubClass(np.ndarray):
def __new__(cls, input_array, info=None):
obj = np.asarray(input_array).view(cls)
obj.info = info
return obj
def __array_finalize__(self, obj):
print('In __array_finalize__:')
print(' self is %s' % repr(self))
print(' obj is %s' % repr(obj))
if obj is None: return
self.info = getattr(obj, 'info', None)
def __array_wrap__(self, out_arr, context=None):
print('In __array_wrap__:')
print(' self is %s' % repr(self))
print(' arr is %s' % repr(out_arr))
# then just call the parent
return super(MySubClass, self).__array_wrap__(self, out_arr, context)
Мы запускаем ufunc над экземпляром нашего нового массива:
>>> obj = MySubClass(np.arange(5), info='spam') In __array_finalize__: self is MySubClass([0, 1, 2, 3, 4]) obj is array([0, 1, 2, 3, 4]) >>> arr2 = np.arange(5)+1 >>> ret = np.add(arr2, obj) In __array_wrap__: self is MySubClass([0, 1, 2, 3, 4]) arr is array([1, 3, 5, 7, 9]) In __array_finalize__: self is MySubClass([1, 3, 5, 7, 9]) obj is MySubClass([0, 1, 2, 3, 4]) >>> ret MySubClass([1, 3, 5, 7, 9]) >>> ret.info 'spam'
Обратите внимание, что ufunc (np.add) вызвал метод __array_wrap__ с аргументами self как obj, и out_arr как (ndarray) результат сложения. В свою очередь, метод по умолчанию __array_wrap__ (ndarray.__array_wrap__) преобразовал результат в класс MySubClass, и вызвал __array_finalize__ - следовательно, копирование атрибута info. Всё это произошло на уровне C.
Но мы могли бы сделать всё, что захотели:
class SillySubClass(np.ndarray):
def __array_wrap__(self, arr, context=None):
return 'I lost your data'
>>> arr1 = np.arange(5) >>> obj = arr1.view(SillySubClass) >>> arr2 = np.arange(5) >>> ret = np.multiply(obj, arr2) >>> ret 'I lost your data'
Таким образом, определив специфический метод __array_wrap__ для нашего подкласса, мы можем настраивать выходные данные ufuncs. Метод __array_wrap__ требует self, затем аргумент — результат ufunc — и необязательный параметр context. Этот параметр возвращается ufuncs в виде кортежа из 3 элементов: (имя ufunc, аргументы ufunc, область ufunc), но не устанавливается другими функциями numpy. Хотя, как показано выше, можно сделать иначе, __array_wrap__ должен возвращать экземпляр содержащего класса. См. подкласс маскированного массива для реализации.
Помимо __array_wrap__, который вызывается при выходе из ufunc, также существует метод __array_prepare__, который вызывается при входе в ufunc, после создания выходных массивов, но до выполнения каких-либо вычислений. Реализация по умолчанию ничего не делает, кроме прохождения массива. __array_prepare__ не должен пытаться получить доступ к данным массива или изменить размер массива, он предназначен для установки типа выходного массива, обновления атрибутов и метаданных и выполнения любых проверок на основе входных данных, которые могут быть желательны перед началом вычислений. Как и __array_wrap__, __array_prepare__ должен возвращать ndarray или подкласс thereof или генерировать ошибку.
Дополнительные особенности - пользовательские методы __del__ и ndarray.base
Одна из проблем, которую решает ndarray, заключается в отслеживании владения памятью ndarrays и их представлений. Рассмотрим случай, когда мы создали ndarray, arr, и взяли срез с v = arr[1:]. Два объекта смотрят на одну и ту же память. NumPy отслеживает, откуда пришли данные для конкретного массива или представления, с атрибутом base.
>>> # A normal ndarray, that owns its own data >>> arr = np.zeros((4,)) >>> # In this case, base is None >>> arr.base is None True >>> # We take a view >>> v1 = arr[1:] >>> # base now points to the array that it derived from >>> v1.base is arr True >>> # Take a view of a view >>> v2 = v1[1:] >>> # base points to the view it derived from >>> v2.base is v1 True
В общем случае, если массив владеет своей памятью, как в случае с arr в данном случае, то arr.base будет None — есть некоторые исключения из этого правила — см. книгу по numpy для более подробной информации.
Атрибут base полезен для определения, имеем ли мы представление или исходный массив. Это, в свою очередь, может быть полезно, если нам нужно знать, нужно ли выполнять определённую очистку при удалении подкласса массива. Например, мы можем выполнять очистку только при удалении исходного массива, но не представлений. Для примера того, как это может работать, ознакомьтесь с классом memmap в numpy.core.
Наследование и совместимость с последующими версиями
При наследовании от ndarray или создании псевдотипов, имитирующих интерфейс ndarray, вы сами решаете, насколько ваши API будут соответствовать API numpy. Для удобства многие функции numpy, у которых есть соответствующий метод ndarray (например, sum, mean, take, reshape ) работают, проверяя, есть ли у первого аргумента функции метод с таким же именем. Если он существует, вызывается метод вместо приведения аргументов к массиву numpy.
Например, если вы хотите, чтобы ваш подкласс или псевдотип были совместимы с функцией numpy sum, сигнатура метода sum этого объекта должна быть следующей:
def sum(self, axis=None, dtype=None, out=None, keepdims=False): ...
Это точно такая же сигнатура метода, как и у np.sum, поэтому, если пользователь вызовет np.sum для этого объекта, numpy вызовет собственный метод объекта sum и передаст эти аргументы, перечисленные выше в сигнатуре, и не будет генерироваться ошибок, поскольку сигнатуры полностью совместимы друг с другом.
Однако, если вы решите отклониться от этой сигнатуры и сделать что-то вроде этого:
def sum(self, axis=None, dtype=None): ...
Этот объект больше не совместим с np.sum, так как если вы вызовите np.sum, он передаст непредсказуемые аргументы out и keepdims, вызвав TypeError.
Если вы хотите сохранить совместимость с numpy и его последующими версиями (которые могут добавить новые ключевые аргументы), но не хотите отображать все аргументы numpy, сигнатура вашей функции должна принимать **kwargs. Например:
def sum(self, axis=None, dtype=None, **unused_kwargs): ...
Этот объект снова совместим с np.sum, так как все лишние аргументы (т.е. ключевые слова, которые не являются axis или dtype ), будут скрыты в параметре **unused_kwargs.
© 2005–2020 NumPy Developers
Licensed under the 3-clause BSD License.
https://numpy.org/doc/1.19/user/basics.subclassing.html