Вход на сайт

Просмотр новости

Найдите то, что Вас интересует

Как скопировать дерево, но не точь-в-точь

Дата публикации: 18-11-2025 08:52:54

На собеседованиях и литкоде любят вращать бинарные деревья. Но что насчёт трансформации обычного дерева в другое? Как решить эту задачу, и какие могут быть подходы? Рассмотрим на опыте трансляции одного синтаксического в другое, чтобы разобраться. Читать далее

Основное содержимое страницы с новостью.

На собеседованиях и литкоде любят вращать бинарные деревья. Но что насчёт трансформации обычного дерева в другое? Как решить эту задачу, и какие могут быть подходы? Рассмотрим на опыте трансляции одного синтаксического в другое, чтобы разобраться.

image1.pngКак мы к этому пришли

Причины трансформировать одно дерево в другое придумать не очень сложно:

  • Конвертация между разными форматами, например XML в HTML.

  • Алгоритмические задачи, включая пресловутое вращение бинарных деревьев.

  • Преобразования внутри компиляторов. Не обязательно дерево в дерево, а, например, синтаксическое дерево в граф потока управления. Зачем это может быть нужно, я уже писал.

Далее буду использовать более точный термин "трансляция" вместо трансформации.

Мы в компании PVS-Studio занимаемся разработкой статических анализаторов кода, так что как раз пересекаемся с компиляторами по инструментарию. В числе прочего, мы также работаем с абстрактным синтаксическим деревом (AST). Но все примеры будут упрощёнными, так что глубоко разбираться в теме не надо.

image2.png

Сама задача родилась хитрым образом:

  1. Нам хочется поддерживать больше языков, пока умеем только в C#, C/C++ и Java. А хочется, в том числе, JavaScript.

  2. Нам, Java-команде, писать новый анализатор хочется на (внезапно) Java.

  3. Самую полноценную поддержку js, а также поддержку ts на будущее даёт компилятор TypeScript. Но написан он на другом языке, так что AST приходится передавать по gRPC. Чтобы это было максимально быстро, используется protobuf.

И как же мы вообще пришли к трансляции?

  • Представление protobuf, которое мы получаем, анализировать невозможно, так как структура неизменяема и объекты полностью теряют иерархию.

  • Хорошей идеей кажется иметь свою обобщённую модель. Тогда мы не зависим от разбирающего код фронтенда, а также упрощаем поддержку новых языков.

Так и родилась наша задача: транслировать дерево ts-компилятора, пережёванное protobuf, в своё представление.

Что транслируем

Так что конкретно мы делаем? Опустим (пока) проблемы с protobuf — нам надо из одного дерева сделать другое. Они оба синтаксические и оба для одного языка, так что разница между ними небольшая. Большинство вершин будут отображаться изоморфно 1 к 1, имея только косметические отличия. Допустим, в одной модели может использоваться именование do...while, а в другой — repeat...until.

image3.png

Т.е. в данном случае у нас преобразование оставляет структуру "как есть", мы её просто дублируем, но могут быть ситуации сложнее. В том же JavaScript есть интерполяция строк, там называемая шаблонными строками.

Если идти дальше, то почему-то в дереве TypeScript выражения внутри интерполяции и текст совмещаются в одну вершину, хотя в других языках интерполяция состоит из плоского списка.

image4.png

Чтобы получить дерево справа, нам нужно из свойств вершины TemplateSpan сделать два (или меньше) узла, проведя разбиение. Так что, помимо прямого преобразования узла в узел, мы получаем преобразование из свойства узла в узел.

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

Пока всё довольно прямолинейно, но что, если исходная структура совсем не совпадает с целевой? В контексте AST мы можем захотеть нормализовать структуру, чтобы в дальнейшем нам было проще с ней работать. Например, всегда добавлять тело в стрелочные функции:

let a = () -> 1; // source
let a = () -> { return 1 }; // normalized
image5.png

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

Итого мы имеем:

  1. Преобразование из узла в узел: прямое дублирование, максимум меняется идентификатор.

  2. Преобразование из свойства в узел: создание дополнительных элементов дерева из свойств исходных узлов.

  3. Набор произвольных правил для нормализации, смысл которых, как и эмоции от них, можно обобщить картинкой ниже.

image6.png

Все эти ситуации мы рассматривали изолированно, но синтаксическое дерево строится целиком на весь файл с исходным кодом, так что на практике всё будет не просто вперемешку, а ещё и в огромном масштабе. Если только в одном объявлении и инициализации let a = 2 + 5 минимум 4 вершины, страшно представить, сколько их будет в файле на тысячи строк кода.

Надеюсь, к этому моменту у вас уже сформировалось впечатление касательно того, что мы пытаемся сделать, потому что пора переходить к практике.

Как залезть на деревоОбход

Прежде чем определиться с тем, как делать такие преобразования, надо понять, как мы будем перемещаться по исходному дереву.

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

Я быстро посчитал количество разных типов вершин на рисунках выше, и оно там в районе десяти — а это буквально три примера. В нашем AST для JavaScript на данный момент немногим менее 100 типов, а в исходной модели их ещё больше. А каждый тип — дополнительный if при решении, как узел обрабатывать. Так что если нам хочется писать код, который можно поддерживать без слёз, то лучше обратиться к паттерну "посетитель".

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

Рекурсивный обходчик дерева
interface Node {
    void accept(Visitor v);
}

interface Visitor {
    void visitBinary(Binary n);
    void visitLiteral(Literal n);
}

class Binary implements Node {
    private final Node left;
    private final Node right;

    public Binary(Node left, Node right) {
        this.left = left;
        this.right = right;
    }

    @Override public void accept(Visitor v) {
        v.visitBinary(this);
    }
}

class Literal implements Node {
    private final int value;

    public Literal(int value) {
        this.value = value;
    }

    @Override public void accept(Visitor v) {
        v.visitLiteral(this);
    }
}

class Scanner implements Visitor {
    public void scan(Node n) {
        n.accept(this); 
    }

    @Override public Binary visitBinary(Binary n) {
        scan(n.getLeft());
        scan(n.getRight());
    }

    @Override public void visitLiteral(Literal n) {
        
    }
}
А как же protobuf?

Внимательный читатель заметит, что модель для обхода нам пережевал protobuf, так о какой двойной диспетчеризации вообще может идти речь? Ни о какой. Почти.

Protobuf действительно причинил боль заставил пораскинуть мозгами. Но по итогу получилось эмулировать посетителя через кодогенерацию обходчика на основе .protoset файла, в котором описаны типы всех узлов. visit-ы у такого посетителя вполне обычные:

@Override
public void visitBinaryExpression(BinaryExpression binaryExpression) {
    scan(binaryExpression.getToken());
    scan(binaryExpression.getLeft());
    scan(binaryExpression.getRight());
}

Достигается это за счёт невинной реализации scan:

public final void scan(Object node) {
    switch (node) {
      case BinaryExpression binaryExpression ->
          visitBinaryExpression(binaryExpression);
      case UnaryExpression unaryExpression -> 
          visitUnaryExpression(unaryExpression);
      case LiteralExpression literalExpression -> 
          visitLiteralExpression(literalExpression);
      // 121 LOC more
    }
}

Элегантным решение назвать сложно, но свою задачу оно выполняет. Так что сойдёмся на следующем:

  1. Посетитель у нас и правда немного необычный.

  2. Принцип работы с ним прежний, так как возможность писать логику в visit-ы без написанных руками if-ов у нас сохранилась.

Трансляция деревьев

Со сценариями трансляции и с обходом разобрались. Пора наконец переходить к самой трансляции.

Узел-узел

Разберёмся с самым простым. Если мы "строим" дерево, то нам его нужно где-то хранить. Самый легкий способ — возвращать из каждого visit-а текущий узел, а также передавать в аргументах родителя. Если мы наследуемся от сканера выше, перед этим добавив ему обобщённые возвращаемое значение и второй аргумент в visit-ы, то мы получим что-то вроде этого:

interface Visitor<R, P> {
    R visitBinary(Binary n, P parent);
    R visitLiteral(Literal n, P parent);
}

class Builder extends Scanner<JsNode, JsNode> {
    @Override
    public JsNode visitBinary(Binary n, JsNode parent) {
        var left = (JsExpression) scan(n.getLeft());
        var right = (JsExpression) scan(n.getRight());
        return new JsBinary(left, right);
    }

    @Override 
    public JsNode visitLiteral(Literal n, JsNode parent) {
        return new JsLiteral(n.getValue());
    }
}

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

Свойство-узел

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

@Override
public JsNode visitTemplateSpan(TemplateSpan templateSpan, JsNode parent) {
    var interpolation = (Interpolation) parent;
    var expr = new InterpolationExpression();
    interpolation.add(expr);
    interpolation.setExpression(
        scan(templateSpan.getExpression())
    );

    // tail is optional
    if (templateSpan.hasTemplateTail()) {
        interpolation.add(new InterpolationText(
            templateSpan.getTemplateTail().getValue()
        ));
    }

    return null;
}

Так как мы пролезли поперёк TemplateSpan, то возвращаем null — мы уже присоединили потомков через родителя, переданного сюда в аргументе.

Нормализация

Свои эмоции в прошлый раз я не просто так выразил такой картинкой — тут случай каждый раз уникальный. Иногда везёт, и нормализацию получается сделать бескровно, иногда приходится заморачиваться. В целом, за правило большого пальца мы взяли себе следующее: нормализацию не делать, если это меняет поток управления или это слишком сложно. При этом понятие "слишком сложно" остаётся на совести разработчика и его менеджера :)

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

Сам visit выглядит примерно так:

@Override
public JsNode visitArrowFunction(
    ArrowFunction arrowFunction,
    JsNode parent
) {
    var func = new JsArrowFunction();
    // link inside
    scan(arrowFunction.getParameterDeclarationList(), func);

    // OK
    if (arrowFunction.hasBlock()) {
        func.setBlock(
            scan(arrowFunction.getBlock())
        );
    } else if (arrowFunction.hasExpression()) {
        var normalized = normalizeArrow(
            arrowFunction
        );
        func.setBlock(normalized);
    }

    return func;
}

Трансформация спрятана в normalizeArrow:

public JsBlock normalizeArrow(ArrowFunction f) {
    var block = new JsCodeBlock();
    block.setFlag(Flag.SYNTHETIC);

    var ret = new JsReturn();
    ret.setFlag(Flag.SYNTHETIC);
    block.addStatement(ret);
    
    var expr = scan(f.getExpression());
    ret.setExpression(expr);

    return block;
}

Помимо самого создания синтетических узлов, мы также помечаем, что они "виртуальные", так как в исходном дереве их не было совсем. Это полезно при статическом анализе — при эвристиках в диагностических правилах мы сможем учитывать, что это не разработчик написал вещи в духе (var) -> { return console.log(var) }, а это просто артефакт наших преобразований. В остальном с преобразованиями на этом всё.

Слон в комнате

К этому моменту мы разобрали все основные принципы, связанные с решением поставленной выше задачи:

  • обход ведём рекурсивно, сохраняя текущее состояние дерева через параметры и возвращаемые значения visit-ов;

  • имеем две типовые трансформации: узел-узел и свойство-узел;

  • имеем одну нетиповую: нормализация дерева;

  • по итогу трансляции мы получаем похожее дерево, но новое, отличающееся в деталях.

Осталось только решить саму задачу. Напомню, что в целевом дереве в районе 100 типов узлов, в исходном — 263 типа. Такая разница во многом обусловлена спецификой protobuf. Это значит, что придётся писать руками visit-ы и scanсотни и тысячи раз. Как вы там, готовы? Я — нет.

image7.png

Решение проблемы, на самом деле, уже появлялось в тексте. Точно так же, как исходный обходчик можно было сгенерировать на основе .protoset файла, так и типовые правила трансляции можно делать при помощи кодогенерации — на основе какого-либо конфигурационного правила. Но об этом, как и о том, как в это втиснуть нетиповую нормализацию, мы поговорим как-нибудь в другой раз.

Послесловие

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

В любом случае, буду рад, если вы подчерпнули для себя что-то новое по работе с деревьями. Чтобы не пропускать статьи по этой теме или новым анализаторам в целом, вы можете подписаться на Твиттер PVS-Studio и наш ежемесячный дайджест статей. Сам инструмент можно бесплатно попробовать здесь.

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

Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Konstantin Volohovsky. How to copy a tree, but not word for word.

Схожие новости

#Наименование новостиТональностьИнформативностьДата публикации
1Почему интервью для разрабов — такое непроходимое говно, и что с этим делать?-5603-07-2026
2Строки кода должны помещаться на экране013.5801-12-2025
3Когда может пригодиться экзотика в ООП: миксины/трейты/аспекты0508-07-2026
4Как считать экономическую эффективность внедрения ИТ-решений. Переводим функционал в деньги3709-07-2026
5Книга: «Object Oriented Design. Подготовка к сложному интервью»012.3604-06-2026
6[Перевод] Понятие о конечных автоматах: руководство разработчика по предсказуемой логике приложений09.6829-05-2026
7Основы вёрстки для дизайнера. Часть 1. Введение: как дизайн превращается в живой сайт5707-07-2026
8[Перевод] Как на самом деле работают LLM0707-07-2026
9Книга: «Фулстек JavaScript: Секреты, которые должен знать каждый мидл»010.3212-05-2026

Классификация: Мнения. Схожих патентов: 0. Схожих новостей: 9. Тональность: 0. Информативность: 9.03. Источник: habr.com.