Нестрогие аргументы по имени в Python?

Вопрос

Есть ли способ объявить аргументы функции нестрогими (передаются по имени) ?

Если это невозможно напрямую: есть ли какие-либо вспомогательные функции или декораторы, которые помогут мне добиться чего-то подобного?


Конкретный пример

Вот небольшая игрушка-пример для экспериментов.

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

num    = "1"

factor = num 
       | "(" + expr + ")"

term   = factor + "*" + term 
       | factor

expr   = term + "+" + expr
       | term

Предположим, что я определяю комбинатор синтаксического анализатора как объект, у которого есть метод parse, который может принимать список токенов, текущую позицию и либо выдавать ошибку синтаксического анализа, либо возвращать результат и новую позицию. Я могу красиво определить базовый класс ParserCombinator, который обеспечивает + (конкатенацию) и | (альтернативу). Затем я могу определить комбинаторы парсеров, которые принимают константные строки, и реализовать + и |:

# Two kinds of errors that can be thrown by a parser combinator
class UnexpectedEndOfInput(Exception): pass
class ParseError(Exception): pass

# Base class that provides methods for `+` and `|` syntax
class ParserCombinator:
  def __add__(self, next):
    return AddCombinator(self, next)
  def __or__(self, other):
    return OrCombinator(self, other)

# Literally taken string constants
class Lit(ParserCombinator):
  def __init__(self, string):
    self.string = string

  def parse(self, tokens, pos):
    if pos < len(tokens):
      t = tokens[pos]
      if t == self.string:
        return t, (pos + 1)
      else:
        raise ParseError
    else:
      raise UnexpectedEndOfInput

def lit(str): 
  return Lit(str)

# Concatenation
class AddCombinator(ParserCombinator):
  def __init__(self, first, second):
    self.first = first
    self.second = second
  def parse(self, tokens, pos):
    x, p1 = self.first.parse(tokens, pos)
    y, p2 = self.second.parse(tokens, p1)
    return (x, y), p2

# Alternative
class OrCombinator(ParserCombinator):
  def __init__(self, first, second):
    self.first = first
    self.second = second
  def parse(self, tokens, pos):
    try:
      return self.first.parse(tokens, pos)
    except:
      return self.second.parse(tokens, pos)

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

# Wrapper that prevents immediate stack overflow
class LazyParserCombinator(ParserCombinator):
  def __init__(self, parserFactory):
    self.parserFactory = parserFactory
  def parse(self, tokens, pos):
    return self.parserFactory().parse(tokens, pos)

def p(parserFactory):
  return LazyParserCombinator(parserFactory)

Это действительно позволяет мне записать грамматику очень близко к РБНФ:

num    = p(lambda: lit("1"))
factor = p(lambda: num | (lit("(") + expr + lit(")")))
term   = p(lambda: (factor + lit("*") + term) | factor)
expr   = p(lambda: (term + lit("+") + expr) | term)

И это действительно работает:

tokens = [str(x) for x in "1+(1+1)*(1+1+1)+1*(1+1)"]
print(expr.parse(tokens, 0))

Однако p(lambda: ...) в каждой строке немного раздражает. Есть ли какой-то идиоматический способ избавиться от него? Было бы неплохо, если бы можно было каким-то образом передать всю RHS правила "по имени", не вызывая нетерпеливого вычисления бесконечной взаимной рекурсии.


Что я пробовал

Я проверил, что доступно в основном языке: кажется, что только if, and и or могут "замыкаться", пожалуйста, поправьте меня, если я ошибаюсь.

Я пытался посмотреть, как это делают другие библиотеки, не относящиеся к игрушкам.

  • Например, funcparserlib использует явные предварительные объявления, чтобы избежать взаимной рекурсии (посмотрите на часть forward_decl и value.define в github). пример кода README.md).

  • parsec.py использует некоторые специальные декораторы @generate и, кажется, делает что-то вроде монадического синтаксического анализа с использованием сопрограмм. Все это очень хорошо, но моя цель — понять, какие варианты у меня есть в отношении основных стратегий оценки, доступных в Python.

Я также нашел что-то вроде lazy_object_proxy.Proxy, но это не похоже чтобы помочь создавать экземпляры таких объектов более кратким способом.

Итак, есть ли более удобный способ передавать аргументы по имени и избегать искажения взаимно рекурсивно определенных значений?


person Andrey Tyukin    schedule 05.04.2018    source источник
comment
Мне кажется, что ваши операции будут оцениваться справа налево. Пока вы придерживаетесь умножения и сложения, это не проблема. Но как только вы включите вычитание и деление, вы обнаружите, что 2 - 3 + 4 вычисляет справа налево совсем по-другому, чем правильное слева направо. Pyparsing (автор которого я) — это еще одна библиотека комбинаторов парсеров Python, на которую вы можете взглянуть — pyparsing также использует явный класс Forward() для рекурсивной грамматики.   -  person PaulMcG    schedule 29.12.2018


Ответы (2)


Это хорошая идея, но это не то, что допускает синтаксис Python: выражения Python всегда оцениваются строго (за исключением блоков if и сокращенных выражений and и or).

В частности, проблема в том, что в таком выражении, как:

num = p(lit("1"))

Аргумент функции p всегда принимается с привязкой нового имени к тому же объекту. Объект, полученный в результате вычисления lit("1"), не имеет имени ничего (пока имя не создается формальным параметром p), поэтому здесь нет имени для привязки. И наоборот, там должен быть объект, иначе p вообще не сможет получить значение.

Что вы можете сделать, так это добавить новый объект для использования вместо лямбда, чтобы отложить оценку имени. Например, что-то вроде:

class DeferredNamespace(object):
    def __init__(self, namespace):
        self.__namespace = namespace
    def __getattr__(self, name):
        return DeferredLookup(self.__namespace, name)

class DeferredLookup(object):
    def __init__(self, namespace, name):
        self.__namespace = namespace
        self.__name = name
    def __getattr__(self, name):
        return getattr(getattr(self.__namespace, self.__name), name)

d = DeferredNamespace(locals())

num = p(d.lit("1"))

В этом случае d.lit на самом деле не возвращает lit, он возвращает объект DeferredLookup, который будет использовать getattr(locals(), 'lit') для разрешения своих членов, когда они действительно используются. Обратите внимание, что это захватывает locals() с нетерпением, чего вам может не хотеться; вы можете адаптировать это для использования лямбда, или, что еще лучше, просто создать все свои сущности в каком-то другом пространстве имен.

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

person Daniel Pryden    schedule 05.04.2018
comment
Извините за, возможно, глупый дополнительный вопрос, но: __getattr__ — это специальный метод, который изменяет то, как работает доступ к членам x.y, но __namespace не является особым, это просто имя переменной, которое вы придумали, верно? Переопределение __getattr__ для отсрочки оценки до тех пор, пока не будет получен доступ к методу parse, безусловно, очень интересная идея, я должен немного подумать об этом. В языках, которые я программировал ранее, не было прямого соответствия __getattr__, так что сейчас я даже не знаю, когда мне захотеть его использовать :] Спасибо за интересное предложение, попробую Это. - person Andrey Tyukin; 05.04.2018
comment
Да, __name и __namespace — это мои собственные имена переменных. (Имена, начинающиеся с двойного подчеркивания, но не заканчивающиеся на единицу, обычно являются определяемыми пользователем закрытыми членами; имена, начинающиеся и заканчивающиеся двойным подчеркиванием, зарезервированы языком.) Возможно, вы захотите прочитать документацию Python по __getattr__ и __getattribute__. -- например, __getattr__ не будет вызываться для элементов, которые уже существуют в объектах DeferredLookup, таких как __str__, но вместо этого вы можете перенаправить некоторые из них в отложенную цель. - person Daniel Pryden; 05.04.2018
comment
Принял этот ответ, потому что он неочевидным образом расширяет набор инструментов. Для конкретного примера комбинаторов синтаксического анализатора я использовал комбинацию декораторов и класса ленивого синтаксического анализатора. Еще раз спасибо. - person Andrey Tyukin; 09.04.2018

Специальное решение для функций, которые должны принимать ровно один аргумент по имени

Если вы хотите определить функцию f, которая должна принимать один единственный аргумент по имени, рассмотрите возможность преобразования f в @decorator. Вместо аргумента, усеянного lambdas, декоратор может напрямую получить определение функции.


lambdas в вопросе появляется, потому что нам нужен способ сделать выполнение правых частей ленивым. Однако если мы изменим определения нетерминальных символов на defs, а не на локальные переменные, RHS также не будет выполняться немедленно. Затем нам нужно каким-то образом преобразовать эти def в ParserCombinator. Для этого мы можем использовать декораторы.


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

def rule(f):
  return LazyParserCombinator(f)

а затем примените его к функциям, которые содержат определения каждого правила грамматики:

@rule 
def num():    return lit("1")

@rule 
def factor(): return num | (lit("(") + expr + lit(")"))

@rule 
def term():   return factor + lit("*") + term | factor

@rule 
def expr():   return (term + lit("+") + expr) | term

Синтаксические накладные расходы в правой части правил минимальны (нет накладных расходов на ссылки на другие правила, не нужны p(...)-обертки или ruleName()-круглые скобки), и нет нелогичного шаблонного кода с лямбда-выражениями.


Объяснение:

Учитывая функцию более высокого порядка h, мы можем использовать ее для украшения другой функции f следующим образом:

@h
def f():
  <body>

Что это делает, по сути:

def f():
  <body>

f = h(f)

и h не ограничен возвратом функций, он также может возвращать другие объекты, например ParserCombinators выше.

person Andrey Tyukin    schedule 05.04.2018
comment
Я был бы признателен, если бы кто-то мог прокомментировать, считается ли это хорошей практикой. По этому определению декоратор просто не работает. Или это нормально, если я делаю это намеренно? - person Andrey Tyukin; 05.04.2018