Посещать или слушать? Дело вкуса – не более. Или нет?
Предыстория.

Разобрав исходный текст, на выходе образовалось дерево:

image

Само по себе дерево не имеет ни какого смысла, оно “Деревянное”, смыслом и какой либо ценностью обладает результат анализа (обхода) этого дерева. Для тех кто не готов напрягаться и писать самописные сани по спуску с дерева (например, меня) в antlr4 добавлена возможность получить анализатор почти бесплатно.

1. Visitor


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

2. Listener


Новшество, появившееся в четвертой версии. Поведение этого класса уже далеко не классическое (Observer или Publish/Subscribe). В классическом исполнении наблюдается менеджер который оповещает подписчиков о наступлении событий. Поведения рассматриваемого слушателя больше похоже на работу инспектора. Инспектор перед проверкой узла делает заметку “Я проверяю Х узел”, далее идет обход потомков узла, после обхода, которых можно сделать “Заключение о результатах обхода узла Х”.

Практика


Для лучшего понимания происходящего обратимся к автору распознавателя pragprog.com/book/tpantlr2/the-definitive-antlr-4-reference. В книге “The Definitive ANTLR 4 Reference” В разделе 8.2 Translating JSON to XML автор производит трансляцию, используя Listener.

Примеры в книге основаны на JAVA, я с JAVA не знаком, но переводятся на C# без болезненно (вот что значит клонирование best practice).

Для приготовления слушателя нам понадобиться VS, C# proj и JASON.g4 с примерно таким наполнением

grammar JASON;

json: object
	| array
	;

object
	: '{' pair ( ',' pair )* '}'	# AnObject
	| '{' '}'						# EmptyObject
	;

	pair: STRING':'value;

array
	: '['value(','value)*']'	# ArrayOfValues
	| '['']'					# EmptyArray
	;

value
	: STRING	# String
	| NUMBER	# Atom
	| object	# ObjectValue
	| array	# ArrayValue
	| 'true'	# Atom
	| 'false'	# Atom
	| 'null'	# Atom
	;

STRING
	: '"' ( ESC | ~["\\] )* '"';

	fragment ESC: '\\'(["\\/bfnrt]|UNICODE);
	fragment UNICODE:'u'HEX HEX HEX HEX;
	fragment HEX:[0-9a-fA-F];

NUMBER
	: '-'? INT'.'INT EXP? //1.35,1.35E-9,0.3,-4.5
	| '-'? INT EXP //1e10-3e4
	| '-'? INT //-3,45
	;

	fragment INT: '0'|[1-9][0-9]*;//noleadingzeros
	fragment EXP: [Ee][+\-]? INT;//\-since-means"range"inside[...]

WS : [\t\n\r]+->skip;


Это грамматика позволяющая распознать JASON. В свойствах файла необходимо выставить Generate Listener и Generate Visitor (это еще пригодиться). Результатом работы Слушателя в оригинальном примере из книги является текст xml, меня это не устраивает я буду получать XElement (все равно xml текст нужно будет во что то переводить, хотя плюс текста в том что он не зажимает в рамки использования конкретных классов).

Алгоритм прост: тк antlr4 использует нисходящий разбор (в нашем случае от корня к узлам), то xml будет формироваться точно так же, создание элемента предка к которому будут добавляться потомки.

Пример Слушателя:

      class XmlListener : JASONBaseListener
	{
		#region fields

		ParseTreeProperty<XElement> xml = new ParseTreeProperty<XElement>();

		#endregion

		#region result

		public XElement Root { get; private set; }

		#endregion

		#region xml api

		XElement GetXml( IParseTree ctx )
		{
			return xml.Get( ctx );
		}

		/// <summary>
		/// поиск родительского xml элемента
		/// </summary>
		XElement GetParentXml( IParseTree ctx )
		{
			var parent = ctx.Parent;

			XElement result = GetXml( parent );

			if ( result == null )
				result = GetParentXml( parent );

			return result;
		}

		void SetXml( IParseTree ctx, XElement e )
		{
			xml.Put( ctx, e );
		}

		#endregion

		#region listener

		public override void ExitString( JASONParser.StringContext context )
		{
			var value = GetStringValue( context.STRING() );

			AddValue( context, value );
		}

		public override void ExitAtom( JASONParser.AtomContext context )
		{
			var value = context.GetText();

			AddValue( context, value );
		}

		public override void EnterPair( JASONParser.PairContext context )
		{
			var name = GetStringValue( context.STRING() );
			XElement element = new XElement( name );

			XElement ParentElement = GetParentXml( context );

			ParentElement.Add( element );
			SetXml( context, element );
		}

		public override void EnterJson( JASONParser.JsonContext context )
		{
			Root = new XElement( "JSON" );
			SetXml( context, Root);
		}

		#endregion

		#region private

		private string GetStringValue( ITerminalNode terminal )
		{
			return terminal.GetText().Trim( '"' );
		}

		private void AddValue( ANTLR_CSV.JASONParser.ValueContext context, string value )
		{
			var parent = GetParentXml( context );

			if ( context.Parent.RuleIndex == JASONParser.RULE_array )
			{
				XElement element = new XElement( "elemnt" );
				element.Value = value;

				parent.Add( element );

				SetXml( context, element );
			}
			else
				parent.Value = value;
		}

		#endregion

	}


EnterJson соответствует входу в узел описанный в грамматике так:

json: object
	| array
	;

ExitString соответствует выходу из узла описанного в грамматике так:

STRING	# String

В отличии от оригинального примера я не использую всех прелестей Enter и Exit. За то есть ParseTreeProperty признанный хранить пары [поддерево, значение], наверное лучше это заменить на обычный словарь (хуже точно не будет).

Пример Посетителя:

class XmlVisitor : JASONBaseVisitor<XElement>
	{
		#region fields

		private XElement _result;

		ParseTreeProperty<XElement> xml = new ParseTreeProperty<XElement>();

		#endregion

		#region xml api

		XElement GetXml( IParseTree ctx )
		{
			return xml.Get( ctx );
		}

		XElement GetParentXml( IParseTree ctx )
		{
			var parent = ctx.Parent;

			XElement result = GetXml( parent );

			if ( result == null )
				result = GetParentXml( parent );

			return result;
		}

		void SetXml( IParseTree ctx, XElement e )
		{
			xml.Put( ctx, e );
		}

		#endregion

		#region visitor

		/// <summary>
		/// значение по умолчанию - создаваемое дерево xml
		/// </summary>
		protected override XElement DefaultResult { get { return _result; } }

		public override XElement VisitJson( JASONParser.JsonContext context )
		{
			_result = new XElement( "JSON" );
			SetXml( context, _result );
			return VisitChildren( context );
		}

		public override XElement VisitString( JASONParser.StringContext context )
		{
			var value = GetStringValue( context.STRING() );

			AddValue( context, value );

			return DefaultResult;
		}

		public override XElement VisitAtom( JASONParser.AtomContext context )
		{
			var value = context.GetText();

			AddValue( context, value );

			return DefaultResult;
		}

		public override XElement VisitPair( JASONParser.PairContext context )
		{
			var name = GetStringValue( context.STRING() );
			XElement element = new XElement( name );

			XElement ParentElement = GetParentXml( context );

			ParentElement.Add( element );
			SetXml( context, element );

			return VisitChildren( context );
		}

		#endregion

		#region private

		private string GetStringValue( ITerminalNode terminal )
		{
			return terminal.GetText().Trim( '"' );
		}

		private void AddValue( ANTLR_CSV.JASONParser.ValueContext context, string value )
		{
			var parent = GetParentXml( context );

			if ( context.Parent.RuleIndex == JASONParser.RULE_array )
			{
				XElement element = new XElement( "elemnt" );
				element.Value = value;

				parent.Add( element );

				SetXml( context, element );
			}
			else
				parent.Value = value;
		}

		#endregion
	}


Как говориться “Найдите 10 отличий”, первое отличие VisitJson, управление посещением без вызова VisitChildren( context ) посещение потомков прекращается, а значит и обход. Каждый из методов посещения должен возвращать значение, то есть всегда есть результат посещения, а это удобно:

var result = visitor.Visit( tree ); 

Когда при работе со слушателем:

walker.Walk( listener, tree );
var result = listener.Root;

В оригинальном примере без Слушателя было бы довольно туго, для данного решения разницы особо нет, но я отдаю свой голос в пользу решения на Посетителе.

Ну и что бы можно было опробовать своими руками:

    private static IParseTree CreateTree()
		{
			StringBuilder sb = new StringBuilder();

			sb.AppendLine( "{" );
			sb.AppendLine( "\"description\":\"Animaginary server config file\"," );
			sb.AppendLine( "\"count\":500," );
			sb.AppendLine( "\"logs\":{\"level\":\"verbose\",\"dir\":\"/var/log\"}," );
			sb.AppendLine( "\"host\":\"antlr.org\"," );
			sb.AppendLine( "\"admin\":[\"parrt\",\"tombu\"]," );
			sb.AppendLine( "\"aliases\":[]" );
			sb.AppendLine( "}" );


			AntlrInputStream input = new AntlrInputStream( sb.ToString() );
			JASONLexer lexer = new JASONLexer( input );
			CommonTokenStream tokens = new CommonTokenStream( lexer );
			JASONParser parser = new JASONParser( tokens );
			IParseTree tree = parser.json();
			return tree;
		}

Пример JSON текста взят практически без изменений из оригинального примера:

    private static void ListenerXml()
		{
			IParseTree tree = CreateTree();

			ParseTreeWalker walker = new ParseTreeWalker();
			XmlListener listener = new XmlListener();
			walker.Walk( listener, tree );
			var result = listener.Root;
		}
     private static void VisitorXml()
		{
			IParseTree tree = CreateTree();

			XmlVisitor visitor = new XmlVisitor();
			var result = visitor.Visit( tree );
		}


Ну и результат выполнения:

<JSON>
  <description>Animaginary server config file</description>
  <count>500</count>
  <logs>
    <level>verbose</level>
    <dir>/var/log</dir>
  </logs>
  <host>antlr.org</host>
  <admin>
    <elemnt>parrt</elemnt>
    <elemnt>tombu</elemnt>
  </admin>
  <aliases />
</JSON>  

Как ни странно, но оба метода выдали одно и то же.

Далее

P.S. Слушатель vs Посетитель – 0: 1

Комментарии (7)


  1. Cobolorum
    08.06.2015 18:55

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


    1. InWake Автор
      08.06.2015 21:40
      +1

      Здесь рассматривается конкретная задача, и ее решение двумя способами. Так же проводиться сравнение двух подходов для решения (да да, я повторяюсь) конкретной задачи. Так же я написал, что если оставить исходную постановку задачи (трансляция JSON в текст XML), по слушатель подойдет лучше.

      И как их правильно комбинировать между собой.
      Сначала подумал «бред», потом все ж пришел к выводу, что это вполне возможно (одно поддерево скушать, а другое посещать). Есть какой ни будь пример (заинтересовало)?
      А можно как то формализовать области применения одного и другого?


  1. datacompboy
    11.06.2015 22:54

    А теперь для посетителя создаём глубокое дерево…


    1. InWake Автор
      14.06.2015 12:53

      для слушателя то же самое дерево, только слушатель обойдет все дерево (обходом нельзя управлять), а посещением можно управлять, например для оптимизированных bool выражений (target != null && target.Value > 100 — если target == null, то правое условие даже проверять не нужно, что удобно. Выполнение же правой части выдаст ошибку)


      1. datacompboy
        14.06.2015 13:13

        Во-1х, для слушателя совсем не обязательно держать всё дерево в памяти.

        Во-2х, слушатель может отписываться когда он закончил, или возвращать подсказки (hint'ы) для обхода (которые, впрочем, автомат может и игнорировать — например, если на одном дереве сейчас параллельно запущено 5 слушателей), впрочем, не вычислять если он уже всё знает он может всегда в любом случае.

        В-3их, построить дерево можно линейно, а вот посетитель расходует стек. А стек это такой мерзкий предмет — то он есть, то его нет.ж


        1. InWake Автор
          14.06.2015 14:03

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


          1. datacompboy
            14.06.2015 14:09

            Если её нет именно у antlr — то это отличный способ внести лепту в опенсорс :)
            а вот оптимизация вычислений таки доступна вне зависимости от возможности отписки.