Строковые дубликаты в исходниках python — вариант решения

от автора

Прежде чем предложить свой взгляд на это явление, хотелось бы описать само явление. Я совершенно не понимаю почему, но в питон исходниках разработчики очень часто дублируют строки. Например есть такой кусок кода:

def send_notification(respondents: list, message: dict) -> None:     for resp in respondents:         to_send = copy(message)         if "subject" not in to_send:             to_send["subject"] = "Hello, " + str(resp)         if "from" not in to_send:             to_send["from"] = None         to_send['body'] = to_send['body'].replace('@respondent@', resp)         to_send['crc'] = crc_code(to_send['body'])

Сразу оговорюсь — я не автор, данный код взят из доклада Григория Бакунова (и он не автор), на одной из конференций посвященных python (есть на youtube). Там рассматривались вопросы производительности, но я хотел бы поговорить не об этом. А о строках — строках «subject», «from», «body» — и подобных, которые дублируются в этом исходнике в виде строковых литералов. И наверняка в этом проекте еще в куче других мест они снова и снова всплывают в виде дублей… Специально взят чужой пример, чтобы обратить внимание, что данная проблема существует, не только в моем личном опыте.

С самого начала изучения языка python я постоянно встречаю подобное в самых разных исходниках разных разработчиков. И это для меня загадка — почему никто не борется с этим дублированием — вроде как DRY он и здесь должен же работать? Нет?

В общем-то чем это плохо:

1) Лишняя память при создании каждого экземпляра той же самой строки — это на самом деле маловероятно в python, т.к. в нем есть механизм автоматического интернирования строк похожих на идентификаторы (коротких с определенными символами). Создается словарь таких строк и они не дублируются в памяти и могут быстро сравниваться (к ним создается хеш), а не посимвольно. Также в зависимости от реализации интерпретатора вообще все строки хранящиеся в исходнике могут подвергаться интернированию. Поэтому про память скорее мимо.

>>> "abc" is "abc"
True
>>> id("abc") == id("abc")
True

Хотя здесь и есть небольшой подвох: строки полученные в рантайм (ввод пользователя, чтение из файла, выражения и т.д.) интернированию не подвергаются в автоматическом режиме. Для принудительного интернирования есть функция sys.intern().

2) Многократно повышается вероятность ошибки опечаткой. Вместо одного места в коде, где строка присваивалась бы в константу, мы то и дело пишем эту строку и шанс получить скажем символ «c» не из латиницы, а кириллицы (а какой сейчас здесь?-глазами вообще не видно…) возрастает многократно. Да современные IDE позволяют проводить поиск и замену по проекту, но что вы будете искать, когда не знаете точно в каком слове опечатка?

3) Сложность поиска ошибок — Ни интерпретатор, ни линтеры нам не помогают такие ошибки найти. Если бы была константа subject_str содержащая «subject», то опечатавшись при упоминании константы мы получили бы ошибку от линтера, а если и нет(нет линтера или он сплоховал), то в рантайме, мы все равно получили бы четкое сообщение об ошибке, т.к. такого идентификатора ( например имя константы с опечаткой «subject_strr» — клавиша залипла, палец дрогнул и т.д. ) просто не существует. А вот неверный строковый литерал просто даст баги в рантайме, возможно вообще без падений, при сравнении с неверной строкой может не выполняться условие никогда и т.д.

4) Это неудобно поддерживать. Если в примере выше тег «subject» по какой-то причине придется заменить например на «topic» — то это как раз превращается в игру с теми самыми средствами поиска и замены в IDE, при этом надо внимательно смотреть каждое включение, ведь не обязательно, что именно все «subject» строки в проекте — это именно то, что надо будет заменить. Если бы строка была объявлена в одном месте константой, можно было бы просто изменить ее значение, при условии конечно, что константа использована согласно логике модулей.

Кстати, инструмент статического анализа кода SonarCube также считает это проблемой https://rules.sonarsource.com/python/RSPEC-1192

Напрашивается простое решение в виде класса-справочника со строковыми константами.

class Colors:     red   = "red"     black = "black"     white = "white" C = Colors  ...  print(C.red)

Конечно получше, но все таки остается дурацкое дублирование в описание класса, которое в 90% случаев будет именно таким и будет мозолить глаза, а также является местом для ошибки, хотя и вероятность ее сильно сокращается. Надо использовать метаклассы — так я сказал своему коллеге и он через 5 мин выдал простое и логичное решение, которым я лично пользуюсь до сих пор. А почему нет? Метаклассы создают классы, участвуют при их создании, атрибуты модифицировать могут, значит они нам подходят.

init_copy = None  class CStrProps_Meta(type):     def __init__(cls, className, baseClasses, dictOfMethods):         for k, v in dictOfMethods.items():             if k.startswith("__"):                 continue             if v is None:                 setattr(cls, k, k) 

Логика работы простая — дандер методы пропускаем, те атрибуты, которые уже инициализированы — пропускаем, а тем, которые заполнены None присваиваем значение равное имени атрибута. Здесь init_copy просто для намека на то, что члены будущего класса словаря будут проинициализированы метаклассом, а вовсе не останутся None, как могло бы показаться при беглом взгляде на класс.

class Colors(metaclass=CStrProps_Meta):     red   = init_copy     black = init_copy     white = init_copy     msg   = "Color is " C = Colors  print(C.msg + C.red)

Можно добавить дополнительный функционал — какой нужен в конкретном проекте, например заблокировать возможность редактирования уже созданных в классе справочнике констант:

class CStrProps_Meta(type):     def __init__(cls, className, baseClasses, dictOfMethods):         cls._items = {}         for k, v in dictOfMethods.items():              if k.startswith("__"):                 continue              if v is None:                 setattr(cls, k, k)              cls._items[k] = getattr(cls, k, k)          cls.bInited = True          def __setattr__(cls, *args):         if hasattr(cls, "bInited"):             raise AttributeError('Cannot reassign members.')         else:             super().__setattr__(*args)

Добавить вывод всех элементов в виде словаря:

    def dict(cls):         return cls._items 

Добавить итератор для возможности обхода циклом for:

    def __iter__(cls):         return iter(cls._items)

Стоит конечно написать и простенький юнит тест:

import unittest  class Dummy_Str_Consts(metaclass = CStrProps_Meta):     name  = init_copy     Error = "[Error:]"     Error_Message = f"{Error}ERROR!!!" DSC = Dummy_Str_Consts  class Test_Str_Consts(unittest.TestCase):     def test_str_consts(self):         self.assertEqual( Dummy_Str_Consts.name,  "name" )          self.assertEqual( Dummy_Str_Consts.Error, "[Error:]" )          self.assertEqual( Dummy_Str_Consts.Error_Message, "[Error:]ERROR!!!" )          l = ["name", "Error", "Error_Message"]         l1 = []         for i in DSC:             l1.append(i)         self.assertEqual( l, l1 )         self.assertEqual( l, list(DSC.dict().keys()) )          l = ["name", "[Error:]", "[Error:]ERROR!!!"]         self.assertEqual( l, list(DSC.dict().values()) )          with self.assertRaises(AttributeError):             DSC.name = "new name"           print(C.msg + C.black)  if __name__ == "__main__":     unittest.main()

В тесте строки намеренно дублируются, так как это проверка описанного механизма и для корректной проверки работы метакласса умышленно вручную вбиваем ожидаемый результат.

При таком подходе вероятность ошибок многократно ниже, их поиск проще, проще и поддержка, можно использовать инструменты рефакторинга (если subject меняем на topic), а можно просто присвоить в переменную лишь в одном месте то, что нужно нам там сейчас.

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


ссылка на оригинал статьи https://habr.com/ru/post/713474/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *