Pull to refresh

Ultimate benchmark пяти с половиной способов проверить наличие атрибута объекта в Python

Reading time 24 min
Views 5.5K
Вот тут подымался вопрос о том, как определить, есть ли у объекта атрибут и как это сделать максимально быстро, однако достаточно глубоко тема исследована не была.


Это, собственно, и послужило причиной написания данной коротенькой статьи. Для тестирования я выбрал следующие (известные мне) способы определения наличия атрибута:
  1. Самый, пожалуй, очевидный — использовать встроенную функцию hasattr(obj, name).
  2. Другой распространенный способ — попытаться обратиться к атрибуту и, если не выйдет, принять меры, обработав AttributeError.
  3. Тоже использовать обработку AttributeError, однако обращаться к свойству не напрямую, а через getattr(obj, name). Ситуация выглядит надуманной, но в реальном применении возможны ситуации, когда имя атрибута для проверки формируется динамически и getattr там как нельзя кстати.
  4. Очень быстрый (см. чуть ниже результаты теста) метод — посмотреть в __dict__ объекта (если он у него есть, конечно). Проблема при применении этого метода заключается, пожалуй, лишь в том, что __dict__'ы раздельны для экземляра класса, самого класса и всех его предков. Если не знать точно, где находится нужный нам атрибут, этот метод не имеет для нас практической ценности. Следует заметить и то, что смотреть в __dict__-ы можно тоже двумя путями — используя методы dict.has_key(key_name) и dict.__contains__(key_name), который соответствует ключевому слову in (assert 'My Key Name' in my_dict). С учетом всех преимуществ, недостатков и двух вариантов реализации __dict__ на два отдельных метода не тянет, считаем его «полуторным».
  5. Последний, самый экзотический метод, заключается в просмотре dir(obj) на предмет имени нужного нам атрибута. Кстати, в процессе проверки вложенных __slots__-классов были обнаружены некоторые интересные моменты, связанные с dir(), но об этом в отдельной статье :)
С методами, надеюсь, разобрались. Для того, чтобы получить более реальную ситуацию, я создал 3 класса, которые наследуются «цепочкой» — TestClass от TestClass2, который, в свою очередь, от TestClass3, предком которого является object. У каждого класса есть «instance attribute» с именем вида c3_ia, назначающийся в конструкторе класса, и «class attribute» c2_ca, определяемый на стадии компиляции класса.

В каждом тесте я пытаюсь получить «instance attribute» и «class attribute» класса верхнего уровня TestClass, «instance attribute» и «class attribute», определенные в классе TestClass3 и какой-то несуществующий атрибут fake.

Все тесты прогонялись 10'000'000 раз. Время, которое в сумме было потрачено на выполнение этих 10M одинаковых операций, и считается временем прохождения теста. Чтобы никому не было обидно, суммарное время высчитывается поровну, для существующих и несуществующих атрибутов.

Вроде всё. Теперь результаты.

Групповой зачет:
dict_lookup_contains     :   5.800250 [2 subtests failed]
dict_lookup              :   7.672500 [2 subtests failed]
hasattr                  :  12.171750 [0 subtests failed]
exc_direct               :  27.785500 [0 subtests failed]
exc_getattr              :  32.088875 [0 subtests failed]
dir                      : 267.500500 [0 subtests failed]

Персональный зачет:
test_dict_lookup_true_this_ca                     : FAILED [AssertionError()]
test_dict_lookup_true_parent_ca                   : FAILED [AssertionError()]
test_dict_lookup_contains_true_this_ca            : FAILED [AssertionError()]
test_dict_lookup_contains_true_parent_ca          : FAILED [AssertionError()]
test_exc_direct_true_this_ca                      :   5.133000
test_exc_direct_true_parent_ca                    :   5.710000
test_dict_lookup_contains_true_parent_ia          :   5.789000
test_dict_lookup_contains_false                   :   5.804000
test_dict_lookup_contains_true_this_ia            :   5.804000
test_exc_direct_true_this_ia                      :   6.037000
test_exc_direct_true_parent_ia                    :   6.412000
test_hasattr_true_this_ca                         :   6.615000
test_exc_getattr_true_this_ca                     :   7.144000
test_hasattr_true_this_ia                         :   7.193000
test_hasattr_true_parent_ca                       :   7.240000
test_dict_lookup_false                            :   7.614000
test_dict_lookup_true_this_ia                     :   7.645000
test_exc_getattr_true_this_ia                     :   7.769000
test_dict_lookup_true_parent_ia                   :   7.817000
test_hasattr_true_parent_ia                       :   7.926000
test_exc_getattr_true_parent_ca                   :   8.003000
test_exc_getattr_true_parent_ia                   :   8.691000
test_hasattr_false                                :  17.100000
test_exc_direct_false                             :  49.748000
test_exc_getattr_false                            :  56.276000
test_dir_true_this_ia                             : 266.847000
test_dir_true_this_ca                             : 267.053000
test_dir_false                                    : 267.398000
test_dir_true_parent_ca                           : 267.849000
test_dir_true_parent_ia                           : 268.663000


В принципе, тут особо комментировать нечего — таблица говорит сама за себя. Краткие итоги:
  • Поиск в __dict__ через in — оптимальное решение, если точно известно, где мы ищем.
  • hasattr показывает стабильно ровную работу при любых запросах, очень хорошо использовать тогда, когда вероятность того, что атрибута не будет, есть.
  • try/except + прямой запрос свойства быстро работает, когда никакого исключения не случается, иначе — сильно чихает (test_exc_direct_false работал аж 49.748 секунд!). Вывод — можно использовать тогда, когда вероятность того, что атрибут будет там, где ему положено быть, очень и очень велика.
  • dir — заслуженный слоупок Python-а. Использовать его для целей проверки наличия атрибута — расстрельная статья.
Вот исходный код тестировщика:

#!/usr/bin/env python
# coding: utf8
 
import time
 
__times__ = 10000000
 
def timeit(func, res):
        '''Check if 'func' returns 'res', if true, execute it '__times__' times (__times__ should be defined in parent namespace) measuring elapsed time.'''
        assert func() == res
       
        t_start = time.clock()
        for i in xrange(__times__):
                func()
        return time.clock() - t_start
 
# Define test classes and create instance of top-level class.
class TestClass3(object):
        c3_ca = 1
       
        def __init__(self):
                self.c3_ia = 1
 
class TestClass2(TestClass3):
        c2_ca = 1
 
        def __init__(self):
                TestClass3.__init__(self)
                self.c2_ia = 2
 
class TestClass(TestClass2):
        c1_ca = 1
       
        def __init__(self):
                TestClass2.__init__(self)
                self.c1_ia = 2
 
obj = TestClass()
 
# Legend:
#
# hasattr, exc_direct, exc_getattr, dict_lookup, dict_lookup_contains, dir - attribute accessing methods.
# true, false - if 'true' we are checking for really existing attribute.
# this, parent - if 'this' we are looking for attribute in the top-level class, otherwise in the top-level class' parent's parent.
# ca, ia - test class attribute ('ca') or instance attribute ('ia') access.
#
# Note about __dict__ lookups: they are not suitable for generic attribute lookup because instance's __dict__ stores only instance's attributes. To look for class attributes we should query them from class' __dict__.
 
# Test query through hasattr
def test_hasattr_true_this_ca():
        return hasattr(obj, 'c1_ca')
 
def test_hasattr_true_this_ia():
        return hasattr(obj, 'c1_ia')
 
def test_hasattr_true_parent_ca():
        return hasattr(obj, 'c3_ca')
 
def test_hasattr_true_parent_ia():
        return hasattr(obj, 'c3_ia')
 
def test_hasattr_false():
        return hasattr(obj, 'fake')
 
# Test direct access to attribute inside try/except
def test_exc_direct_true_this_ca():
        try:
                obj.c1_ca
                return True
        except AttributeError:
                return False
 
def test_exc_direct_true_this_ia():
        try:
                obj.c1_ia
                return True
        except AttributeError:
                return False
 
def test_exc_direct_true_parent_ca():
        try:
                obj.c3_ca
                return True
        except AttributeError:
                return False
 
def test_exc_direct_true_parent_ia():
        try:
                obj.c3_ia
                return True
        except AttributeError:
                return False
 
def test_exc_direct_false():
        try:
                obj.fake
                return True
        except AttributeError:
                return False
 
# Test getattr access to attribute inside try/except
def test_exc_getattr_true_this_ca():
        try:
                getattr(obj, 'c1_ca')
                return True
        except AttributeError:
                return False
 
def test_exc_getattr_true_this_ia():
        try:
                getattr(obj, 'c1_ia')
                return True
        except AttributeError:
                return False
 
def test_exc_getattr_true_parent_ca():
        try:
                getattr(obj, 'c3_ca')
                return True
        except AttributeError:
                return False
 
def test_exc_getattr_true_parent_ia():
        try:
                getattr(obj, 'c3_ia')
                return True
        except AttributeError:
                return False
 
def test_exc_getattr_false():
        try:
                getattr(obj, 'fake')
                return True
        except AttributeError:
                return False
 
# Test attribute lookup in dir()
def test_dir_true_this_ca():
        return 'c1_ca' in dir(obj)
 
def test_dir_true_this_ia():
        return 'c1_ia' in dir(obj)
 
def test_dir_true_parent_ca():
        return 'c3_ca' in dir(obj)
 
def test_dir_true_parent_ia():
        return 'c3_ia' in dir(obj)
 
def test_dir_false():
        return 'fake' in dir(obj)
 
# Test attribute lookup in __dict__
def test_dict_lookup_true_this_ca():
        return obj.__dict__.has_key('c1_ca')
 
def test_dict_lookup_true_this_ia():
        return obj.__dict__.has_key('c1_ia')
 
def test_dict_lookup_true_parent_ca():
        return obj.__dict__.has_key('c3_ca')
 
def test_dict_lookup_true_parent_ia():
        return obj.__dict__.has_key('c3_ia')
 
def test_dict_lookup_false():
        return obj.__dict__.has_key('fake')
 
# Test attribute lookup in __dict__ through __contains__
def test_dict_lookup_contains_true_this_ca():
        return 'c1_ca' in obj.__dict__
 
def test_dict_lookup_contains_true_this_ia():
        return 'c1_ia' in obj.__dict__
 
def test_dict_lookup_contains_true_parent_ca():
        return 'c3_ca' in obj.__dict__
 
def test_dict_lookup_contains_true_parent_ia():
        return 'c3_ia' in obj.__dict__
 
def test_dict_lookup_contains_false():
        return 'fake' in obj.__dict__
 
# TEST
tests = {
        'hasattr': {
                'test_hasattr_true_this_ca': True,
                'test_hasattr_true_this_ia': True,
                'test_hasattr_true_parent_ca': True,
                'test_hasattr_true_parent_ia': True,
                'test_hasattr_false': False,
        },
        'exc_direct': {
                'test_exc_direct_true_this_ca': True,
                'test_exc_direct_true_this_ia': True,
                'test_exc_direct_true_parent_ca': True,
                'test_exc_direct_true_parent_ia': True,
                'test_exc_direct_false': False,
        },
        'exc_getattr': {
                'test_exc_getattr_true_this_ca': True,
                'test_exc_getattr_true_this_ia': True,
                'test_exc_getattr_true_parent_ca': True,
                'test_exc_getattr_true_parent_ia': True,
                'test_exc_getattr_false': False,
        },
        'dict_lookup': {
                'test_dict_lookup_true_this_ca': True,
                'test_dict_lookup_true_this_ia': True,
                'test_dict_lookup_true_parent_ca': True,
                'test_dict_lookup_true_parent_ia': True,
                'test_dict_lookup_false': False,
        },
        'dict_lookup_contains': {
                'test_dict_lookup_contains_true_this_ca': True,
                'test_dict_lookup_contains_true_this_ia': True,
                'test_dict_lookup_contains_true_parent_ca': True,
                'test_dict_lookup_contains_true_parent_ia': True,
                'test_dict_lookup_contains_false': False,
        },
        'dir': {
                'test_dir_true_this_ca': True,
                'test_dir_true_this_ia': True,
                'test_dir_true_parent_ca': True,
                'test_dir_true_parent_ia': True,
                'test_dir_false': False,
        },
}
 
# Perform tests
results = {}
results_exc = {}
 
for (test_group_name, test_group) in tests.iteritems():
        results_group = results[test_group_name] = {}
        results_exc_group = results_exc[test_group_name] = {}
        for (test_name, test_expected_result) in test_group.iteritems():
                test_func = locals()[test_name]
                print '%s::%s...' % (test_group_name, test_name)
                try:
                        test_time = timeit(test_func, test_expected_result)
                        results_group[test_name] = test_time
                except Exception, exc:
                        results_group[test_name] = None
                        results_exc_group[test_name] = exc
 
# Process results
group_results = []
 
for (group_name, group_tests) in results.iteritems():
        group_true_time = 0.0
        group_true_count = 0
        group_false_time = 0.0
        group_false_count = 0
        group_fail_count = 0
       
        for (test_name, test_time) in group_tests.iteritems():
                if test_time is not None:
                        if tests[group_name][test_name]:
                                group_true_count += 1
                                group_true_time += test_time
                        else:
                                group_false_count += 1
                                group_false_time += test_time
                else:
                        group_fail_count += 1
       
        group_time = (group_true_time / group_true_count + group_false_time / group_false_count) / 2
        group_results.append((group_name, group_time, group_fail_count))
 
group_results.sort(key = lambda (group_name, group_time, group_fail_count): group_time)
 
# Output results
print
print 'Групповой зачет:'
 
for (group_name, group_time, group_fail_count) in group_results:
        print '%-25s: %10f [%d subtests failed]' % (group_name, group_time, group_fail_count)
 
print 'Персональный зачет:'
all_results = []
for (group_name, group_tests) in results.iteritems():
        for (test_name, test_time) in group_tests.iteritems():
                all_results.append((group_name, test_name, test_time))
all_results.sort(key = lambda (group_name, test_name, test_time): test_time)
 
for (group_name, test_name, test_time) in all_results:
        if test_time is not None:
                print '%-50s: %10f' % (test_name, test_time)
        else:
                print '%-50s: FAILED [%r]' % (test_name, results_exc[group_name][test_name])


Чуть не забыл… Компьютер, на котором выполнялся тест: CPU: Intel Pentium D CPU 3.40GHz (2 ядра (но использовалось, очевидно, только одно)); RAM: 2Gb. Если это кому-то интересно, конечно.

Update #1 (Результаты теста на __getattribute__ и сравнение с предыдущими результатами):
Использовался следующий класс в качестве замены предыдущей цепочке:

__attributes__ = ('c1_ca', 'c3_ca', 'c1_ia', 'c3_ia')
class TestClass(object):
        def __getattribute__(self, name):
                if name in __attributes__:
                        return 1
                else:
                        raise AttributeError()


Результаты замера (с учётом getattr(obj, name, None) is not None)

Групповой зачет:
dict_lookup              :        n/a [5 subtests failed]
dict_lookup_contains     :        n/a [5 subtests failed]
hasattr                  :  20.181182 [0 subtests failed]
getattr                  :  26.283962 [0 subtests failed]
exc_direct               :  41.779489 [0 subtests failed]
exc_getattr              :  47.757879 [0 subtests failed]
dir                      :  98.622183 [4 subtests failed]

Персональный зачет:
test_dir_true_parent_ia                           : FAILED [AssertionError()]
test_dir_true_this_ia                             : FAILED [AssertionError()]
test_dir_true_this_ca                             : FAILED [AssertionError()]
test_dir_true_parent_ca                           : FAILED [AssertionError()]
test_dict_lookup_true_parent_ia                   : FAILED [AttributeError()]
test_dict_lookup_true_this_ia                     : FAILED [AttributeError()]
test_dict_lookup_true_this_ca                     : FAILED [AttributeError()]
test_dict_lookup_true_parent_ca                   : FAILED [AttributeError()]
test_dict_lookup_false                            : FAILED [AttributeError()]
test_dict_lookup_contains_true_this_ia            : FAILED [AttributeError()]
test_dict_lookup_contains_true_parent_ia          : FAILED [AttributeError()]
test_dict_lookup_contains_true_parent_ca          : FAILED [AttributeError()]
test_dict_lookup_contains_true_this_ca            : FAILED [AttributeError()]
test_dict_lookup_contains_false                   : FAILED [AttributeError()]
test_exc_direct_true_this_ca                      :  13.346949
test_exc_direct_true_parent_ca                    :  13.970407
test_exc_direct_true_this_ia                      :  14.621696
test_hasattr_true_this_ca                         :  15.077735
test_exc_direct_true_parent_ia                    :  15.146182
test_exc_getattr_true_parent_ca                   :  16.305500
test_getattr_true_this_ia                         :  16.976973
test_hasattr_true_parent_ia                       :  17.196719
test_hasattr_true_parent_ca                       :  17.613231
test_getattr_true_this_ca                         :  18.331266
test_exc_getattr_true_parent_ia                   :  18.720518
test_hasattr_false                                :  21.983571
test_getattr_true_parent_ca                       :  22.087115
test_exc_getattr_true_this_ca                     :  23.072045
test_hasattr_true_this_ia                         :  23.627484
test_getattr_true_parent_ia                       :  24.474635
test_getattr_false                                :  32.100426
test_exc_getattr_true_this_ia                     :  34.555669
test_exc_direct_false                             :  69.287669
test_exc_getattr_false                            :  72.352324
test_dir_false                                    :  98.622183


Теперь переходим к сравнению результатов…

Ключ: имя группы время, потраченное на нормальных классах [количество сбоев на них же] | среднее между обоими показателями по времени -- соотношение __getattribute__-показателя к нормальному | время, потраченное на __getattribute__-классах [количество сбоев там же]
Групповой зачет (сортировка по среднему времени):
dict_lookup              :   7.672500 [2] |        n/a --        n/a |        n/a [5]
dict_lookup_contains     :   5.800250 [2] |        n/a --        n/a |        n/a [5]
hasattr                  :  12.171750 [0] |  16.176466 --   1.658035 |  20.181182 [0]
getattr                  :  15.350072 [0] |  20.817017 --   1.712302 |  26.283962 [0]
exc_direct               :  27.785500 [0] |  34.782495 --   1.503644 |  41.779489 [0]
exc_getattr              :  32.088875 [0] |  39.923377 --   1.488300 |  47.757879 [0]
dir                      : 267.500500 [0] | 183.061342 --   0.368680 |  98.622183 [4]

Групповой зачет (сортировка по соотношению):
dict_lookup              :   7.672500 [2] |        n/a --        n/a |        n/a [5]
dict_lookup_contains     :   5.800250 [2] |        n/a --        n/a |        n/a [5]
dir                      : 267.500500 [0] | 183.061342 --   0.368680 |  98.622183 [4]
exc_getattr              :  32.088875 [0] |  39.923377 --   1.488300 |  47.757879 [0]
exc_direct               :  27.785500 [0] |  34.782495 --   1.503644 |  41.779489 [0]
hasattr                  :  12.171750 [0] |  16.176466 --   1.658035 |  20.181182 [0]
getattr                  :  15.350072 [0] |  20.817017 --   1.712302 |  26.283962 [0]


Ключ: имя теста время, потраченное на нормальных классах | среднее между обоими показателями по времени -- соотношение __getattribute__-показателя к нормальному | время, потраченное на __getattribute__-классах
Персональный зачет (сортировка по среднему времени):
test_dict_lookup_true_parent_ia     &nbsp
Tags:
Hubs:
+16
Comments 16
Comments Comments 16

Articles