`Cannot find a version of '*' that satisfies the version constraints` というエラーがでた時

Android開発で、Gradleから以下のようなエラーが出た:

> Task :app:lint FAILED

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':app:lint'.
> Could not resolve all artifacts for configuration ':${submodule}:debugAndroidTestRuntimeClasspath'.
   > Could not resolve com.android.support:support-annotations:28.0.0.
     Required by:
         project :${submodule}
      > Cannot find a version of 'com.android.support:support-annotations' that satisfies the version constraints: 
           Dependency path '${project}:${submodule}:unspecified' --> 'com.android.support:support-annotations:28.0.0'
           Constraint path '${project}:${submodule}:unspecified' --> 'com.android.support:support-annotations' strictly '26.1.0' because of the following reason: debugRuntimeClasspath uses version 26.1.0

これはなにかというと、マルチモジュールなプロジェクトで依存関係を組んでいる時、

  • 上位モジュールが要求するライブラリがstrictにバージョンを指定している
  • その下位のモジュールは当該ライブラリを直接利用はしていない
  • が、このモジュールの依存する他のライブラリが要求するため、間接的に依存している
    • そしてこの間接的に使われる当該ライブラリバージョンは上位のモノと一致していない
  • 結果、ライブラリバージョンがconflictしているために決定できない

という状況で発生する。


すなわちやることはシンプルで、下位モジュールにも同様に設定すればいい。
今回の場合は、上位のモジュールで28.0.0が打たれているcom.android.support:support-annotations が、下位のモジュールではバージョンがunspecifiedになっているわけなので、その依存を追加すればいい:

...
dependencies {
    implementation 'com.android.support:support-annotations:28.0.0'
}
...

Android Lintで`Unknown issue id "all"`が出る場合

Android Lintを掛けようとして、こんなエラーが出た:

Error: Unknown issue id "all", found in /${projectdir}/lint.xml [LintError]

設定ファイルの当該箇所はこれ:

<?xml version="1.0" encoding="UTF-8"?>
<lint>
    ...
    <issue id="all"> <!-- 🈁 -->
        <ignore path="..."/>
        ...
    </issue>
    ...
</lint>

使っていたAndroid Gradle Pluginはこれ:

...
buildscript {
    ...
    dependencies {
        classpath 'com.android.tools.build:gradle:3.2.1'
        ...
    }
}
...

これはなにかというと、単にAndroid Gradle Pluginのバグである。 ここで報告されていた
この問題はAndroid Gradle Plugin 3.3.0で既に修正されている。

...
buildscript {
    ...
    dependencies {
        classpath 'com.android.tools.build:gradle:3.3.0'
        ...
    }
}
...

ただし、このAndroid Gradle Plugin 3.3.0は要求するGradleバージョンの引き上げも含まれるため注意。

> Failed to apply plugin [id 'com.android.application']
   > Minimum supported Gradle version is 4.10.1. Current version is [currentversion]. If using the gradle wrapper, try editing the distributionUrl in /${projectdir}/gradle/wrapper/gradle-wrapper.properties to gradle-4.10.1-all.zip

具体的には、gradle-4.10.1以上にしなければいけない。

./gradlew wrapper --gradle-version=4.10.1
# 
# Deprecated Gradle features were used in this build, making it incompatible with Gradle 5.0.
# See https://docs.gradle.org/4.8.1/userguide/command_line_interface.html#sec:command_line_warnings
# 
# BUILD SUCCESSFUL in 5s
# 1 actionable task: 1 executed
git status
# On branch ${yourbranch}
# Your branch is ahead of 'origin/${yourbranch}' by ${num} commit.
#   (use "git push" to publish your local commits)
# 
# Changes not staged for commit:
#   (use "git add <file>..." to update what will be committed)
#   (use "git checkout -- <file>..." to discard changes in working directory)
# 
#  modified:   gradle/wrapper/gradle-wrapper.properties
# 
# no changes added to commit (use "git add" and/or "git commit -a")
./gradlew wrapper
# 
# Deprecated Gradle features were used in this build, making it incompatible with Gradle 5.0.
# Use '--warning-mode all' to show the individual deprecation warnings.
# See https://docs.gradle.org/4.10.1/userguide/command_line_interface.html#sec:command_line_warnings
# 
# BUILD SUCCESSFUL in 5s
# 1 actionable task: 1 executed
git status
# On branch ${yourbranch}
# Your branch is ahead of 'origin/${yourbranch}' by ${num} commit.
#   (use "git push" to publish your local commits)
# 
# Changes not staged for commit:
#   (use "git add <file>..." to update what will be committed)
#   (use "git checkout -- <file>..." to discard changes in working directory)
# 
#  modified:   gradle/wrapper/gradle-wrapper.jar
#  modified:   gradle/wrapper/gradle-wrapper.properties
# 
# no changes added to commit (use "git add" and/or "git commit -a")

Gitリポジトリの中身を全部一気に消す

Gitリポジトリの中のファイルを一括で消すコマンドといえば、よく言われるのはこれ:

git rm -rf .

しかしこれはちょっと問題があって、

  • GitでUntrackedなファイルは削除対象じゃない
  • 削除対象ファイルが0コの場合はエラーになる

というちょっと扱いづらいもの。


ならば当然こういう選択肢が出る:

rm -rf ./*

ただこれだと今度は.gitディレクトリまで削除されてしまう。


そこでオススメするonelinearはコチラ:

git clean -fdx && test $(git ls-files | wc -l) -eq 0 || git rm -rf .

ちょっと解説。

git clean -fdx

git clean -fdxのオプションは、

  • -f: force
  • -d: ディレクトリを対象に
  • -x: ignore対象も削除

これでまずUntrackedなファイル含めクリーンに。

&& test $(git ls-files | wc -l) -eq 0

次に test $(git ls-files | wc -l) -eq 0 では、

  • gitの管理するファイル一覧の行数をカウントし、
  • ファイル数が0ならOK、
  • そうでない場合はエラーにする

という内容。

|| git rm -rf .

最後に注目すべきは、||でつなげてあるということ。
これにより、手前のtestが失敗している(= ファイルが残っている)なら実行するし、成功している(= ファイルがもうない)なら実行されない。

よって、エラーを吐かせずにリポジトリの中身がクリーンにできちゃう。

sonarqube-gradle-pluginで `Component key * not found` となった時

org.sonarsource.scanner.gradle:sonarqube-gradle-plugin を使っていて、設定次第ではまれにこのようなエラーが出る場合がある:

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':sonarqube'.
> Unable to load component class org.sonar.scanner.report.MetadataPublisher

* Try:
Run with --debug option to get more log output. Run with --scan to get full insights.

* Exception is:
org.gradle.api.tasks.TaskExecutionException: Execution failed for task ':sonarqube'.
    at org.gradle.api.internal.tasks.execution.ExecuteActionsTaskExecuter.executeActions(ExecuteActionsTaskExecuter.java:103)
    at org.gradle.api.internal.tasks.execution.ExecuteActionsTaskExecuter.execute(ExecuteActionsTaskExecuter.java:73)
    at org.gradle.api.internal.tasks.execution.OutputDirectoryCreatingTaskExecuter.execute(OutputDirectoryCreatingTaskExecuter.java:51)
    at org.gradle.api.internal.tasks.execution.SkipUpToDateTaskExecuter.execute(SkipUpToDateTaskExecuter.java:59)
    at org.gradle.api.internal.tasks.execution.ResolveTaskOutputCachingStateExecuter.execute(ResolveTaskOutputCachingStateExecuter.java:54)
    at org.gradle.api.internal.tasks.execution.ValidatingTaskExecuter.execute(ValidatingTaskExecuter.java:59)
    at org.gradle.api.internal.tasks.execution.SkipEmptySourceFilesTaskExecuter.execute(SkipEmptySourceFilesTaskExecuter.java:101)
    at org.gradle.api.internal.tasks.execution.FinalizeInputFilePropertiesTaskExecuter.execute(FinalizeInputFilePropertiesTaskExecuter.java:44)
    at org.gradle.api.internal.tasks.execution.CleanupStaleOutputsExecuter.execute(CleanupStaleOutputsExecuter.java:91)
    at org.gradle.api.internal.tasks.execution.ResolveTaskArtifactStateTaskExecuter.execute(ResolveTaskArtifactStateTaskExecuter.java:62)
    at org.gradle.api.internal.tasks.execution.SkipTaskWithNoActionsExecuter.execute(SkipTaskWithNoActionsExecuter.java:59)
    at org.gradle.api.internal.tasks.execution.SkipOnlyIfTaskExecuter.execute(SkipOnlyIfTaskExecuter.java:54)
    at org.gradle.api.internal.tasks.execution.ExecuteAtMostOnceTaskExecuter.execute(ExecuteAtMostOnceTaskExecuter.java:43)
    at org.gradle.api.internal.tasks.execution.CatchExceptionTaskExecuter.execute(CatchExceptionTaskExecuter.java:34)
    at org.gradle.execution.taskgraph.DefaultTaskGraphExecuter$EventFiringTaskWorker$1.run(DefaultTaskGraphExecuter.java:256)
    at org.gradle.internal.progress.DefaultBuildOperationExecutor$RunnableBuildOperationWorker.execute(DefaultBuildOperationExecutor.java:336)
    at org.gradle.internal.progress.DefaultBuildOperationExecutor$RunnableBuildOperationWorker.execute(DefaultBuildOperationExecutor.java:328)
    at org.gradle.internal.progress.DefaultBuildOperationExecutor.execute(DefaultBuildOperationExecutor.java:199)
    at org.gradle.internal.progress.DefaultBuildOperationExecutor.run(DefaultBuildOperationExecutor.java:110)
    at org.gradle.execution.taskgraph.DefaultTaskGraphExecuter$EventFiringTaskWorker.execute(DefaultTaskGraphExecuter.java:249)
    at org.gradle.execution.taskgraph.DefaultTaskGraphExecuter$EventFiringTaskWorker.execute(DefaultTaskGraphExecuter.java:238)
    at org.gradle.execution.taskgraph.DefaultTaskPlanExecutor$TaskExecutorWorker.processTask(DefaultTaskPlanExecutor.java:123)
    at org.gradle.execution.taskgraph.DefaultTaskPlanExecutor$TaskExecutorWorker.access$200(DefaultTaskPlanExecutor.java:79)
    at org.gradle.execution.taskgraph.DefaultTaskPlanExecutor$TaskExecutorWorker$1.execute(DefaultTaskPlanExecutor.java:104)
    at org.gradle.execution.taskgraph.DefaultTaskPlanExecutor$TaskExecutorWorker$1.execute(DefaultTaskPlanExecutor.java:98)
    at org.gradle.execution.taskgraph.DefaultTaskExecutionPlan.execute(DefaultTaskExecutionPlan.java:663)
    at org.gradle.execution.taskgraph.DefaultTaskExecutionPlan.executeWithTask(DefaultTaskExecutionPlan.java:597)
    at org.gradle.execution.taskgraph.DefaultTaskPlanExecutor$TaskExecutorWorker.run(DefaultTaskPlanExecutor.java:98)
    at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:63)
    at org.gradle.internal.concurrent.ManagedExecutorImpl$1.run(ManagedExecutorImpl.java:46)
    at org.gradle.internal.concurrent.ThreadFactoryImpl$ManagedThreadRunnable.run(ThreadFactoryImpl.java:55)
Caused by: java.lang.IllegalStateException: Unable to load component class org.sonar.scanner.report.MetadataPublisher
    at org.sonar.core.platform.ComponentContainer$ExtendedDefaultPicoContainer.getComponent(ComponentContainer.java:65)
    at org.picocontainer.DefaultPicoContainer.getComponent(DefaultPicoContainer.java:621)
    at org.picocontainer.parameters.CollectionComponentParameter.getArrayInstance(CollectionComponentParameter.java:334)
    at org.picocontainer.parameters.CollectionComponentParameter.access$100(CollectionComponentParameter.java:49)
    at org.picocontainer.parameters.CollectionComponentParameter$1.resolveInstance(CollectionComponentParameter.java:139)
    at org.picocontainer.parameters.ComponentParameter$1.resolveInstance(ComponentParameter.java:141)
    at org.picocontainer.injectors.SingleMemberInjector.getParameter(SingleMemberInjector.java:78)
    at org.picocontainer.injectors.ConstructorInjector$CtorAndAdapters.getParameterArguments(ConstructorInjector.java:309)
    at org.picocontainer.injectors.ConstructorInjector$1.run(ConstructorInjector.java:335)
    at org.picocontainer.injectors.AbstractInjector$ThreadLocalCyclicDependencyGuard.observe(AbstractInjector.java:270)
    at org.picocontainer.injectors.ConstructorInjector.getComponentInstance(ConstructorInjector.java:364)
    at org.picocontainer.injectors.AbstractInjectionFactory$LifecycleAdapter.getComponentInstance(AbstractInjectionFactory.java:56)
    at org.picocontainer.behaviors.AbstractBehavior.getComponentInstance(AbstractBehavior.java:64)
    at org.picocontainer.behaviors.Stored.getComponentInstance(Stored.java:91)
    at org.picocontainer.DefaultPicoContainer.instantiateComponentAsIsStartable(DefaultPicoContainer.java:1034)
    at org.picocontainer.DefaultPicoContainer.addAdapterIfStartable(DefaultPicoContainer.java:1026)
    at org.picocontainer.DefaultPicoContainer.startAdapters(DefaultPicoContainer.java:1003)
    at org.picocontainer.DefaultPicoContainer.start(DefaultPicoContainer.java:767)
    at org.sonar.core.platform.ComponentContainer.startComponents(ComponentContainer.java:135)
    at org.sonar.core.platform.ComponentContainer.execute(ComponentContainer.java:122)
    at org.sonar.scanner.task.ScanTask.execute(ScanTask.java:48)
    at org.sonar.scanner.task.TaskContainer.doAfterStart(TaskContainer.java:82)
    at org.sonar.core.platform.ComponentContainer.startComponents(ComponentContainer.java:136)
    at org.sonar.core.platform.ComponentContainer.execute(ComponentContainer.java:122)
    at org.sonar.scanner.bootstrap.GlobalContainer.executeTask(GlobalContainer.java:131)
    at org.sonar.batch.bootstrapper.Batch.doExecuteTask(Batch.java:116)
    at org.sonar.batch.bootstrapper.Batch.executeTask(Batch.java:111)
    at org.sonarsource.scanner.api.internal.batch.BatchIsolatedLauncher.execute(BatchIsolatedLauncher.java:63)
    at org.sonarsource.scanner.api.internal.IsolatedLauncherProxy.invoke(IsolatedLauncherProxy.java:60)
    at com.sun.proxy.$Proxy96.execute(Unknown Source)
    at org.sonarsource.scanner.api.EmbeddedScanner.doExecute(EmbeddedScanner.java:233)
    at org.sonarsource.scanner.api.EmbeddedScanner.runAnalysis(EmbeddedScanner.java:151)
    at org.sonarqube.gradle.SonarQubeTask.run(SonarQubeTask.java:99)
    at org.gradle.internal.reflect.JavaMethod.invoke(JavaMethod.java:73)
    at org.gradle.api.internal.project.taskfactory.StandardTaskAction.doExecute(StandardTaskAction.java:46)
    at org.gradle.api.internal.project.taskfactory.StandardTaskAction.execute(StandardTaskAction.java:39)
    at org.gradle.api.internal.project.taskfactory.StandardTaskAction.execute(StandardTaskAction.java:26)
    at org.gradle.api.internal.AbstractTask$TaskActionWrapper.execute(AbstractTask.java:788)
    at org.gradle.api.internal.AbstractTask$TaskActionWrapper.execute(AbstractTask.java:755)
    at org.gradle.api.internal.tasks.execution.ExecuteActionsTaskExecuter$1.run(ExecuteActionsTaskExecuter.java:124)
    at org.gradle.internal.progress.DefaultBuildOperationExecutor$RunnableBuildOperationWorker.execute(DefaultBuildOperationExecutor.java:336)
    at org.gradle.internal.progress.DefaultBuildOperationExecutor$RunnableBuildOperationWorker.execute(DefaultBuildOperationExecutor.java:328)
    at org.gradle.internal.progress.DefaultBuildOperationExecutor.execute(DefaultBuildOperationExecutor.java:199)
    at org.gradle.internal.progress.DefaultBuildOperationExecutor.run(DefaultBuildOperationExecutor.java:110)
    at org.gradle.api.internal.tasks.execution.ExecuteActionsTaskExecuter.executeAction(ExecuteActionsTaskExecuter.java:113)
    at org.gradle.api.internal.tasks.execution.ExecuteActionsTaskExecuter.executeActions(ExecuteActionsTaskExecuter.java:95)
    ... 30 more
Caused by: java.lang.IllegalStateException: Unable to load component class org.sonar.scanner.rule.ModuleQProfiles
    at org.sonar.core.platform.ComponentContainer$ExtendedDefaultPicoContainer.getComponent(ComponentContainer.java:65)
    at org.picocontainer.DefaultPicoContainer.getComponent(DefaultPicoContainer.java:632)
    at org.picocontainer.parameters.BasicComponentParameter$1.resolveInstance(BasicComponentParameter.java:118)
    at org.picocontainer.parameters.ComponentParameter$1.resolveInstance(ComponentParameter.java:136)
    at org.picocontainer.injectors.SingleMemberInjector.getParameter(SingleMemberInjector.java:78)
    at org.picocontainer.injectors.ConstructorInjector$CtorAndAdapters.getParameterArguments(ConstructorInjector.java:309)
    at org.picocontainer.injectors.ConstructorInjector$1.run(ConstructorInjector.java:335)
    at org.picocontainer.injectors.AbstractInjector$ThreadLocalCyclicDependencyGuard.observe(AbstractInjector.java:270)
    at org.picocontainer.injectors.ConstructorInjector.getComponentInstance(ConstructorInjector.java:364)
    at org.picocontainer.injectors.AbstractInjectionFactory$LifecycleAdapter.getComponentInstance(AbstractInjectionFactory.java:56)
    at org.picocontainer.behaviors.AbstractBehavior.getComponentInstance(AbstractBehavior.java:64)
    at org.picocontainer.behaviors.Stored.getComponentInstance(Stored.java:91)
    at org.picocontainer.DefaultPicoContainer.getInstance(DefaultPicoContainer.java:699)
    at org.picocontainer.DefaultPicoContainer.getComponent(DefaultPicoContainer.java:647)
    at org.sonar.core.platform.ComponentContainer$ExtendedDefaultPicoContainer.getComponent(ComponentContainer.java:63)
    ... 75 more
Caused by: Failed to load the quality profiles of project 'project': Component key 'project' not found

最後のここに注目:

Caused by: Failed to load the quality profiles of project 'project': Component key 'project' not found

この Component key '*' not found の部分がプロジェクトのルートディレクトリ名だった場合、恐らく問題は簡単。

ドキュメントによると、このプラグインはprojectKey設定の際、デフォルトではルートのプロジェクト名を用いる。
そしてGradleはルートのプロジェクトにプロジェクト名が設定されていない場合、そのプロジェクトのルートディレクトリ名を用いる。

開発現場ではルートディレクトリ名はGitリポジトリ名とリンクしていることが多いため、開発者ほぼ全員が偶然期待通りの設定がデフォルト値として用いられる。
しかし例えばCircleCIなどのSaaSでは、プロジェクトがcloneされる先が ~/project となるため、この前提が崩れるのである。

解決方法

このように環境によって不安定な状態で用いるのは望ましくないが、手っ取り早く治すなら プロジェクトルートにある settings.gradle へ以下の1行を書き足せば良い。

rootProject.name = '期待するディレクトリ名'

余談

私はこれで2営業日とかしました

Makefile上でGitHubのPull Request URLからPull Request Numberを取り出す方法

たとえばCIRCLE_PULL_REQUESTという環境変数にフルのGitHub Pull Requestの URLが入っているとする。具体的にはこんな感じ:

export CIRCLE_PULL_REQUEST=https://github.com/octocat/Spoon-Knife/issues/1
echo $CIRCLE_PULL_REQUEST
# https://github.com/octocat/Spoon-Knife/issues/1

この変数からpullreq-numberを取り出すのは、通常のシェルではカンタンに実現できる:

echo ${CIRCLE_PULL_REQUEST##*/}
# 1

しかしMakefile上では事前に変数が展開されてしまうため、以下のようなファイルを用意し:

SHELL:=/bin/sh

test:
        echo blam! && \
                echo ${CIRCLE_PULL_REQUEST##*/} && \
                echo 'done.'

実行すると:

make test
# echo blam! && \
#      echo  && \
#      echo 'done.'
# blam!
# 
# done.

うまくいかないのである。


すなわちescapeすればよい:

SHELL:=/bin/sh

test:
        echo blam! && \
                echo $${CIRCLE_PULL_REQUEST##*/} && \
                echo 'done.'

diff

5c5
<       echo ${CIRCLE_PULL_REQUEST##*/} && \
---
>        echo $${CIRCLE_PULL_REQUEST##*/} && \

これでうまくいく。

make test
# echo blam! && \
#      echo ${CIRCLE_PULL_REQUEST##*/} && \
#      echo 'done.'
# blam!
# 1
# done.

変数に収めたい時はこうするとよい:

SHELL:=/bin/sh
CIRCLE_PR_NUMBER?=$(shell echo $${CIRCLE_PULL_REQUEST\#\#*/})

test:
        echo blam! && \
                echo ${CIRCLE_PR_NUMBER} && \
                echo 'done.'
make test
# echo blam! && \
#      echo 1 && \
#      echo 'done.'
# blam!
# 1
# done.

Mockitoの`verify`カウント数をリセットする方法

Mockito はとても便利で、たとえばこんな風にするだけで「このメソッドが叩かれたか?」というチェックができる:

MyObj obj = spy(new MyObj());

doNothing().when(obj).firedMyEvent(); // 実際の処理を無視しておく

obj.doSomething(); // 内部で1回だけ`MyObj#firedMyEvent`が叩かれる
verify(obj, times(1)).firedMyEvent(); // ここでassert

よくある利用ケースとして、何回か同じイベントが叩かれることをチェックしたい時がある:

MyObj obj = spy(new MyObj());

doNothing().when(obj).firedMyEvent();
{
    obj.doSomething();
    verify(obj, times(1)).firedMyEvent();
}
// ここで`reset(obj)`すればいいか?
{
    obj.doSomething(); // もう一回呼ぶ
    verify(obj, times(1)).firedMyEvent(); // またここで発火していたことを確認したいが...?
}

しかし上記のテストは失敗する。obj自身はinvocationのカウンタを持ち続けているのでtimes(1)ではなくtimes(2)になってしまうわけだ。
安直に org.mockito.Mockito.reset でリセットしてしまうと、冒頭のdoNothingでmockした箇所までリセットしてしまい面倒だ。

カウンタだけリセットし、また1からカウントするには以下のようにすればよい:

MyObj obj = spy(new MyObj());

doNothing().when(obj).firedMyEvent();
{
    obj.doSomething();
    verify(obj, times(1)).firedMyEvent();
}
clearInvocations(obj); // org.mockito.Mockito.clearInvocations
{
    obj.doSomething();
    verify(obj, times(1)).firedMyEvent(); // OK
}

clearInvocationsの引数はvarargsになっているので、第2、第3と引数へどんどん繋げていって一括リセットできる。

Kotlinで中置記法四則演算to逆ポーランド記法

以前、プログラミング学び直しシリーズとして Kotlinによる逆ポーランド記法四則演算機 を作った。その続きとして、Kotlin/JVMで中置記法の計算式を逆ポーランド記法に変換するコードを書いた。

enum class Operator(
    val value: Char
) {
    ADD('+'),
    SUB('-'),
    MUL('*'),
    DIV('/'),
    BRACKET_OPEN('('),
    BRACKET_CLOSE(')'),
    //
    ;

    fun match(x: Char?): Boolean
            = this.value == x

    fun match(x: String?): Boolean
            = x?.length == 1 && match(x.first())

    fun str(): String
            = value.toString()

}

// `char`がいずれかの演算記号と合致するか
fun Char.isOperator(): Boolean
        = Operator.values().map { it.value }.contains(this)

data class Node(
    val expr: List<String>,
    val left: Node? = null,
    val right: Node? = null
)

fun main(args: Array<String>) {
    val expr = tokenize(args[0])

    val tree = process(
        Node(expr)
    )

    println(
        traversePostOrder(tree).joinToString(" ")
    )
}

fun tokenize(src: String): List<String> {
    val tokens: MutableList<String> = mutableListOf()
    var currentNumbers = ""

    repeat(src.length) { i ->
        val x = src[i]

        if (x.isWhitespace()) {
            return@repeat // 空白は削除
        } else if (x.isOperator()) {
            if (currentNumbers.isNotEmpty()) {
                // 何かしらの記号だった場合、ここまでの連続した数値をtokenとして追加
                tokens.add(currentNumbers)
            }
            currentNumbers = ""
            tokens.add(x.toString()) // 記号を追加
        } else {
            currentNumbers += x // 連続した数値を記録
        }
    }

    // 終端まで来たら残った内容を追加
    if (currentNumbers.isNotEmpty()) {
        tokens.add(currentNumbers)
    }

    return tokens
}

// 再帰で自身より下位の数式を処理
fun process(tree: Node): Node {
    val expr = removeMostOuterBrackets(tree.expr)
    val i = searchLowPriorityOperatorPosition(expr)

    if (i == null) {
        return tree.copy(expr) // 自身より下位が無ければ括弧を削除したものを返し終了
    } else {
        return Node(
            expr = listOf(expr[i]), // 最も優先度が低く右側の記号
            left = process(Node(
                expr.slice(0 .. (i - 1)) // 頭から記号の手前まで
            )),
            right = process(Node(
                expr.slice((i + 1) .. (expr.size - 1)) // 記号直後から終端まで
            ))
        )
    }
}

// 両側の括弧が対応していれば削除する
fun removeMostOuterBrackets(expr: List<String>): List<String> {
    if (!Operator.BRACKET_OPEN.match(expr.firstOrNull()))
        return expr // 最初が括弧でなければ削除する必要が無い

    var nest = 0

    repeat(expr.size) { i ->
        when (expr[i]) {
            Operator.BRACKET_OPEN.str() -> {
                nest++
            }
            Operator.BRACKET_CLOSE.str() -> {
                nest--

                // 最後の文字に来る前に開始した括弧が全て閉じられてしまった場合、両側の削除は不要 (できない)
                if (i != (expr.size - 1) && nest == 0)
                    return expr
            }
        }
    }

    return removeMostOuterBrackets(
            expr.slice(1 .. (expr.size - 2) )
    )
}

// 最も優先順位の低い演算子を探す
fun searchLowPriorityOperatorPosition(expr: List<String>): Int? {
    var ret: Int? = null
    var lastPriority = Int.MAX_VALUE // 優先順位の低いものがほしいため

    var nest = 0
    var priority = 0

    repeat(expr.size) {
        when (expr[it]) {
            Operator.ADD.str(), Operator.SUB.str() ->
                priority = 1
            Operator.MUL.str(), Operator.DIV.str() ->
                priority = 2
            Operator.BRACKET_OPEN.str() -> run {
                nest++
                return@repeat
            }
            Operator.BRACKET_CLOSE.str() -> run {
                nest--
                return@repeat
            }
            else -> run { return@repeat }
        }

        // 優先順位が低く、かつ最も後に登場したものを最後に返す
        if (0 == nest && priority <= lastPriority) { // ネスト0 = 優先順位が低い
            lastPriority = priority
            ret = it
        }
    }

    return ret
}

// 後行順序訪問
fun traversePostOrder(tree: Node): List<String> {
    var ret: MutableList<String> = mutableListOf()

    tree.left?.let { ret.addAll(traversePostOrder(it)) }
    tree.right?.let { ret.addAll(traversePostOrder(it)) }

    return ret + tree.expr
}

たとえば以下のように入力すると:

(((3+3+4)*(334+(3/3-4))*(3*3*4)+(3+3-4))*(3-34)+(3-(3+4)))*(3-34)+(3-(3-4))

以下のように出力される:

3 3 + 4 + 334 3 3 / 4 - + * 3 3 * 4 * * 3 3 + 4 - + 3 34 - * 3 3 4 + - + 3 34 - * 3 3 4 - - +

さて、これを担当している授業で高校生に教えようとしているのだが、どう説明していこうかな。