環境構築メモ ~ Visual Studio Code + WSL2 + Ubuntu + Docker + LaTeX のコンパイル環境

目的

LaTeX コンパイル環境を構築したい。LaTeX はモジュールや文字フォント等で複雑な依存関係がありそう、ということで、構築失敗による環境汚染を避けるために Ubuntu Docker コンテナで実現したい。LaTeX の文書作成 & PDF 閲覧は Windows 上の Visual Studio Code で行いたい。(あれもこれも、欲張りだなぁ ...)

前提

Windows 10 上には Visual Studio Code と SumatraPDF *1 が、WSL2 上には Ubuntu 20.04 LTS と Docker が既に導入されているものとする。

成果

以下の手順を踏むと Visual Studio Code にて次のことが可能になる。

  • Ctrl + Alt + B (Build) でビルド
  • Ctrl + Alt + V (View PDF) で PDF 閲覧
  • Ctrl + Alt + C (Clear intermediate files) で中間ファイル削除

一度、ビルドが通ると、編集 → ファイル保存、するだけで自動リビルドされる。

TeX 記述サポートにおいても、@a と書くだけで \alpha に補完される、など Visual Studio Code はかなり便利。LaTeX Workshop のスニペットこちら

Ubuntu での LaTeX 実行環境構築

こちらの記事 によると Docker で日本語 LaTeX をやるには paperist/alpine-texlive-ja イメージで uplatex コマンドを使うのがよいらしい。ということで、このイメージを Docker Hub から docker pull しておく。次に ~/.bashrc に以下のように記述しておく。

# latex through docker
function func_tex2pdf() {
  docker run --rm -it -v /mnt/d/[working folder path on raw Ubuntu]/$1:/workdir paperist/alpine-texlive-ja latexmk -pdfdvi -latex=uplatex -e '$dvipdf=q/dvipdfmx %O -o %D %S/;' -synctex=1 -silent $1.tex;
}

alias tex2pdf=func_tex2pdf

これで WSL2 Ubuntuコマンドラインから tex2pdf Sample と打つことで、Docker コンテナをワンタイム起動し、コンテナ内の作業フォルダ /workdir にある tex ファイルから同フォルダに pdf が生成できるようになる。ここでのポイントは以下のとおり。

  • コンテナの /workdir-v オプションでホスト Ubuntu の作業フォルダにマウントしている
  • さらに当該 Ubuntu フォルダの実体は Windows 側の NTFS ドライブ (/mnt/d/ すなわち d:/) にある
  • 環境変数 $1 にはコマンドの第1引数 (上述の例では Sample) が入り、共通フォルダの下のサブフォルダ Sample を作業フォルダとしてその中の Sample.tex を対象ファイルとしている
  • 環境変数$1alias 文内で2度使えないため、関数定義にする
  • オプション -synctec は後述 (Ubuntu 環境では利用する予定なし)
  • uplatex や dvipdf による一連の作業を latexmk を用いたワンライナーで実行する (texwiki 参照)
  • 当該イメージ内に dvipdf がないため、代わりに dvipdfmx を用いることを Perl 表記の引数で指示している *2
  • マウント対象パスのスペース文字はエスケープシーケンス \ ("\" + Space) になる

Visual Studio CodeLaTeX 作業環境構築

こちらの記事 に従い、Visual Studio CodeLaTeX Workshop 拡張機能をインストールする。

Visual Studio CodeJSON 設定

次に Power Shell から WSL を通じて上述の Docker イメージのワンタイム起動を行うように Visual Studio Code に設定する。ただし先ほど定義した .bashrc の tex2pdf エイリアスをそのまま利用することはできない。Visual Studio Code の設定 (JSON ファイル *3 ) には以下のように記述する。

    // -- LaTeX Workshop --
        // 使用パッケージのコマンドや環境の補完を有効にする
        "latex-workshop.intellisense.package.enabled": true,

        // 生成ファイルを削除するときに対象とするファイル
        // デフォルト値に "*.synctex.gz" を追加
        "latex-workshop.latex.clean.fileTypes": [
            "*.aux", "*.bbl", "*.blg", "*.idx", "*.ind", "*.lof",
            "*.lot", "*.out", "*.toc", "*.acn", "*.acr", "*.alg",
            "*.glg", "*.glo", "*.gls", "*.ist", "*.fls", "*.log",
            "*.fdb_latexmk", "*.snm", "*.nav", "*.dvi",
            // "*.synctex.gz", // 中間ファイル消去後も SyncTeX 利用の場合はコメントアウト
        ],
    
        // ビルドのレシピ
        "latex-workshop.latex.recipes": [
            {
                "name": "tex2pdf",
                "tools": [
                    "tex2pdf",
                ]
            },
        ],
    
        // ビルドのレシピに使われるパーツ
        "latex-workshop.latex.tools": [
            {
                "name": "tex2pdf",
                "command": "wsl",
                "args": [
                    "docker",
                    "run",
                    "--rm",
                    "-v",
                    "/mnt/d/[working folder path on raw Ubuntu]/%DOCFILE%:/workdir",
                    "paperist/alpine-texlive-ja",
                    "sh",
                    "-c",
                    "latexmk -latex=uplatex -synctex=1 -silent %DOCFILE%.tex && dvipdfmx %DOCFILE%.dvi"
                ],
            },
        ],

        // PDF Viewer の表示方法
        "latex-workshop.view.pdf.viewer": "tab",      // Internal PDF Viewer
     // "latex-workshop.view.pdf.viewer": "external", // External PDF Viewer

        // SyncTeX
        "latex-workshop.view.pdf.internal.synctex.keybinding": "double-click", // or "ctrl-click" 
        "latex-workshop.synctex.afterBuild.enabled": true,
        "latex-workshop.synctex.path": "synctex",
        "latex-workshop.synctex.synctexjs.enabled": true,

        // Internal PDF Viewer Settings
        "latex-workshop.view.pdf.zoom": "page-width",

        // External PDF Viewer
        "latex-workshop.view.pdf.external.viewer.command": "c:/Users/[user name]/AppData/Local/SumatraPDF/SumatraPDF.exe",
        "latex-workshop.view.pdf.external.viewer.args": [
          "-reuse-instance",
          "%PDF%",
        ],
        "latex-workshop.view.pdf.external.synctex.command": "c:/Users/[user name]/AppData/Local/SumatraPDF/SumatraPDF.exe",
        "latex-workshop.view.pdf.external.synctex.args": [
            "-reuse-instance",
            "%PDF%",
            "-forward-search",
            "%TEX%", // Sumatra PDF がなぜかこのパスを正しく認識しないため、順方向検索が無効
            "%LINE%",
            "-inverse-search",
            "\"c:\\Users\\[user name]\\AppData\\Local\\Programs\\Microsoft VS Code\\bin\\code.cmd\" -r -g \"%f:%l\"",
        ],

ここまでの作業により、Visual Studio Code のビルドコマンドを実行すると、WSL と Docker を通じたワンライナーTeX 文書をコンパイルするようになる。ここまでのポイントは以下のとおり。

  • Power Shell から起動する WSLbash は対話シェルではないため、.bashrc が反映されない
  • したがって、Power Shell から WSL コマンドでワンライナー起動する
  • 対話シェルではないため、docker コマンドの引数 -it は付けない
  • JSON 設定の latex-workshop.latex.tools セクションに記述するワンライナー.bashrc と若干違うのは、dvipdf 設定の引数部分である Perl 記述が (どうエスケープシーケンスを書いても) 失敗してうまく起動しないため
  • 環境変数 %DOC% は用いず %DOCFILE% のみを用いるのが吉 (%DOC% の挙動が不安定で何度かハマっている。 参考記事 参照)
  • オプション -synctex によりコンパイル時に .synctex.gz ファイルを生成し、これにより tex と pdf 両ファイルでの項目位置を紐づけ、クリックによる相互ジャンプを可能にする (後述)
  • マウント対象パスのスペース文字はエスケープシーケンス不要になる
PDF Viewer の設定

この記事この記事 を参考に synctex 関連を設定する。

Sumatra PDF を起動し、メニュー > 三 > 設定 > 詳細設定 を開き、設定ファイルの下記部分を修正する *4

EnableTeXEnhancements = true
InverseSearchCmdLine = "c:\Users\[user name]\AppData\Local\Programs\Microsoft VS Code\bin\code.cmd" -r -g "%f:%l"

これにより、Sumatra PDF をダブルクリックしたときに、逆順検索 (ダブルクリック箇所に対応する Visual Studio Code 上の LaTeX ファイル該当箇所へジャンプ) する。

課題

Visual Studio Code 内タブの PDF ビューアも外部 PDF ビューア Sumatra PDF も起動・表示・ビルド時再読み込みはうまく動作するが、いまのところ synctex は外部 PDF ビューアからの逆順検索しか成功しない ...
内部ビューアの場合は、おそらく Visual Studio Code 内の Ctrl 押しながら系の操作がキャンセルかインタラプトされている。
外部ビューアの場合は、Sumatra PDF が -forward-search の次の引数のファイルパスを正しく読み込めていない。不明なソースファイル (C:\ ..... \Sample.tex) と表示される。そのパスにファイルがあるにも関わらず。
継続調査。

*1:PDF をファイルロックせず、更新時に即時再読み込みをしてくれる PDF ビューア。Windows 上で Visual Studio Code + LaTeX Workshop 利用する時の推奨ビューア。

*2:クォーテーションがネストするため、Perl 部分は q// で表現する。

*3:%UserProfile%\AppData\Roaming\Code\User\settings.json

*4:ここでは、環境変数 %UserProfile% は無効。

Visual Studio Code へのエディタ移行

四半世紀の間、使い続けてきた emacs 系エディタから VS Code に移行しようかと思う。

  • 第1の理由 ... Visual Studio の代替
  • 第2の理由 ... コーディング支援 (のシーン限定での利用可能性)
    • JavaScript などたまにしか書かない動的型付け言語は、短いコードであれど、慣れた emacs 系エディタで書くよりも、コード補完やシンタックスハイライトがある VS Code の方が便利で速い、という事実に改めて気づいた。
    • よく書く静的型付け言語 (C#) では、基本 API が頭に入っており、思考やタイピングがコード補完よりも圧倒的に速いため、コーディングの妨げにしかならない支援 (... か?) をする IDE を嫌っていた。が、文脈 (言語) 別にカスタマイズすればよいわけで、シンプル・エディタ至上原理主義を卒業してよいのかもと思うようになった。
  • 第3の理由 ... emacs を代替するエディタ機能
    • emacs 系エディタでよく使う機能が何か棚卸してみたところ、後述する1機能を除けば、以下の通りだった。これらはすべて VS Code でもできる。むしろ 2 - 5 については emacs ではショートカットキーに続けてコマンドをタイプしなければならないところを、VS Code ではショートカットキーだけにできそう。
      • 1. undo (取消) / redo (やり直し)
      • 2. search-forward (逐次検索) / query-replace (逐次置換)
      • 3. copy-rectangle (矩形選択コピー) / yank-rectangle (矩形貼り付け)
      • 4. replace-regexp (正規表現置換)
      • 5. change-buffer-encoding (エンコーディング変換)
      • 6. シェル呼び出し / コンパイル
  • 第4の理由 ... 前方互換性と入手容易性
    • write once, run anywhere というか accustomed once, utilize anywhere の予感がする。VS Code は次世代のデファクトスタンダードになってきたし、OS を選ばず、さらに Windows から WSL に対しても使える。Visual Studio やお気に入りの emacs 系エディタをよその環境にインストールしてくれとリクエストしても却下されがちだが、VS Code はそのハードルが低くなりそう。この点と第3の理由から、emacs キーバインド拡張機能を入れずに、素の VS Code キーアサインに慣れておくのもよいかもと思うまでになった。
    • VS Code の出現でシェアが下がった emacs の利用継続にリスクを感じたことに端を発し、メンテナンスが止まってかなり立つ xyzzy*1 を常用している現状をどうにかする必要があると考えた。
  • 第5の理由 ... 利用サービス/アプリのオフライン・オンライン間統合
    • 第4の理由の延長線上にある欲求として、利用するサービス / アプリがオフライン・オンライン間で分離することを許容せず、統合したい、という思いがある。同じ作業をするときは、ローカルでもクラウドでも、同じサービスを同じ設定で使いたい。VS Code は近いうちに Web サービスにもなりそうであるため、期待している。その背景にあるものは次のとおり。
    • 出張や旅行で海外に出てもコンピュータ環境が脆弱にならないように、長いこと軽量かつ高性能なモバイル PC を使い続けてきた*2 が、最近はスマホタブレットにモバイル PC に ... と高性能 CPU と大容量ストレージをいくつも保持するのが経済的にバカらしいと思うようになってきた*3。そこでここ2年くらいは VPN + RDP を活用する前提で、ハイエンド・デスクトップ PC + ミドルクラス軽量 2 in 1 端末 + ローエンド・スマホ、に収斂させる方向で環境整備しようとしている。2 in 1 端末は軽量・安価・必要以上にパワフルでないことが重要で、テキスト編集、VPN + RDP、スマホ用途 (Web ブラウズ、SNS、メッセ、動画視聴) ができればよい。という状況で、出先でちょっとテキスト編集するときにはオフラインローカルで、本格的に使いたいときには自宅サーバにつなぐかクラウドサービスで、でもスペックの違うそれぞれ環境に固有のサービスではなく、同じものを同じ設定で使うという Chrome Book 的な活用コンセプト*4 をパワフルでない 2 in 1 端末で実現できたら理想だと考えている。そのためには軽量で anywhere 的な (でもいざとなったらヘビーな使い方もできる) エディタが必要になる。
    • 環境整備のもう1つの系譜としてタスク / メモ管理がある。こちらも四半世紀の間、付箋紙アプリ → FitzNote → DropBoxEvernoteOneNoteGoogle Keep と試してきて、最近 Notion *5 に乗り換え / 一本化することにした。同じ作業をするのにローカルとクラウドで道具や操作を泣き別れさせたくないという欲求は強い。


右サイドバーにコード・ナビゲータが表示されるとか、Jupyter と併用するときになんとなく UI が似ている*6 とか、VS Code 移行によるメリットはその他にも多数ある。

一方で、どうしても解消できないデメリットが1つだけあり、それこそが本格移行をいままで渋っていた理由でもある。それは emacs のようなタブストップ&ラインインデント*7 が効かないこと。emacs 以外のエディタは、なぜかタブキーをタブ入力か複数スペース入力にしか設定できない。これは諦めて新スタンダードに合わせる必要があるか ...

*1:Windows 上で動く emacs ライクな軽量テキストエディタ。2014 年 4 月を最後にメンテナンスが止まっている。

*2:Dynabook SS → Libretto 50 → VAIO type-p → VAIO Duo 11 → ... その後、小型モバイル PC は当たりに恵まれず ... をネット黎明期から海外に持ち歩いているモバイラーである。

*3:ひどい時には、会社 PC + 個人 PC + 会社スマホ + 個人スマホ + 個人タブレットで合計 5kg 程度をハンドキャリーすることがあり、重量の観点でもバカらしかった。

*4:Chrome Book はコンセプト的には「軽量、安価、でもオフィスワークできる」だが、実際には WAN 回線がないモデルが大多数だし、野暮ったくて重量があるため、持ち歩くには実用的ではない。B5 サイズ弱、タイプカバー付きで 650g 前後、Geekbench 5 (64bit MultiCore) スコア 2500 ~ 3000、LTE あり、USB Type-C OTG + DP Alternate mode あり、 5 万円程度が理想。現時点でこのコンセプトを最も体現しているのは Chrome OS ではないが Huawei Mate Pad 10 になる。

*5:コードと結果とメモを混在させて記述・記録できる Jupyter のように外部ソースと自分のアイディアを取り混ぜて簡単に記述できるのが Notion の利点。

*6:VS Code と Jupyter の使い分け・棲み分けを考えなければならない。

*7:文脈から決まる、各行におけるインデント位置にカーソルとコード先頭をジャンプさせること。どこにいてもあるべきインデントに整形してくれるため、うっかりスペースを打ったりしてもタブを押せば戻せるし、うっかりタブを打っても何も起こらない。オフサイドルールの Python には必須の機能ではないかと思うのだが、なぜか emacs 以外に実装しているエディタがないように見受けられる。

C# on Jupyter からの Numpy.NET 利用

Jupyter から C# で Numpy.NET ライブラリを利用できないかと思い、

#r "nuget: Numpy, 3.7.1.25"
using Numpy;

と記述するも、結論としては NG。このようなランタイムエラーが出る。

System.DllNotFoundException: Unable to load shared library 'python37' or one of its dependencies. In order to help diagnose loading problems, consider setting the LD_DEBUG environment variable: libpython37: cannot open shared object file: No such file or directory
   at Python.Runtime.Runtime.Py_IsInitialized()
   at Python.Runtime.Runtime.Initialize(Boolean initSigs)
   at Python.Runtime.PythonEngine.Initialize(IEnumerable`1 args, Boolean setSysArgv, Boolean initSigs)
   at Python.Runtime.PythonEngine.Initialize(Boolean setSysArgv, Boolean initSigs)
   at Python.Runtime.PythonEngine.Initialize()
   at Numpy.np.InstallAndImport(Boolean force)
...


環境は、WSL2, Ubuntu 20.04LTS, Docker (tensorflow/tensorlow-latest-py3-jupter) イメージ, Jupyter Lab, Python3.6, .NET Interactive, dotnet-sdk-5.0 (ver. 5.0.302)。エラーを克服すべく試行錯誤すること半日。apt-get による Python 3.7 の追加インストール、.NET SDK 5.0.202 の追加インストール、python37.dll の追加配置、環境変数追加等々、エラーから推測されることを試すもすべてダメ。.NET Interactive ではなく Windows 上の Visual Studioコンパイルすると OK。

調べていくと Numpy は Numpy → Python.Included → Python.Deployment → pythonnet_netstandard_py37_win というパッケージ依存があり、この pythonnet_netstandard_py37_winC# から Python につなぐブリッジなのだが、その解説には

Python and CLR (.NET and Mono) cross-platform language interop. Compiled against .NetStandard 2.0 and CPython 3.7 (win64)

とある! Python and CLR (.NET and Mono) cross-platform language interop なのに、繋ぐ先として CPython 3.7 (win64) を想定してコンパイルしてあるだと!

クロスプラットフォーム .NET 5 のおかげで Windows でも Ubuntu でも PythonC# が (ビルド後 dll でもあっても) 使える write once, run anywhere 状態なのに、ブリッジ先が win64Python パッケージであるため、Jupyter (= Ubuntu) 上で記述した C# から接続できない … それは cross-language ではあっても cross-platform ではないのではないか ...


代わりに NumSharp ライブラリは使える。

#r "nuget: NumSharp, 0.30.0"
using NumSharp;

var x = ((NDArray) new double[] { 1, 2, 3, 4 }).reshape(2, 2);
var y = ((NDArray) new double[] { 4, 3, 2, 1 }).reshape(2, 2);

Console.WriteLine(x.dot(y).ToString()); // 行列の積
Console.WriteLine((x * y).ToString());  // アダマール積

FizzBuzz

学生時代に情報科学科の同級生たちと「FizzBuzz 問題というものがあるらしいが、この程度の基礎ができないプログラマなどいるはずがない」と会話していた。

それから四半世紀。小学生にはプログラミングの授業があり、非 IT 企業の一般社員にも「Python 書いて RPA しろ、HTML/CSS/JavaScript で社内サイト作って情報発信しろ」という圧が掛かってくる DX 社会。DX とはデジタル革命, IT, Web2.0, ICT に続く "企業経営者向け IT 売り込みバズワード" だと思っていたが、一般の人々にまでやれ!やれ!と煽り立てる世の中の圧が今回はすごい。

猫も杓子もコードを書けという時代になった *1 せいか、Quora などでも FizzBuzz 書けない問題をよく目にする。

ということで、自分なら FizzBuzz どう書くか、C#Perl でやってみた。

C# 9.0 (標準解)
Enumerable.Range(1, 30)
  .Select( i => (i % 3, i % 5) switch {
    (0, 0) => "FizzBuzz",
    (0, _) => "Fizz"    ,
    (_, 0) => "Buzz"    ,
    (_, _) => "" + i    ,
  } )
  .ToList().ForEach( x => Console.WriteLine(x) );

所要 1 分半。using, namespace, class, Main() メソッドの宣言が不要な C# スクリプトとして記述した。

普段使いの C# のため、コアロジックのコーディングに悩むこともリファレンスを参照することもなく、呼吸するようにタイピングする。純粋関数 (LINQ によるデータ変換) と I/O (WriteLine() による出力) をきれいに分離した上で後者をどう書くかというのが C# LINQ を使う上で最も時間を費やす悩みどころ。結局、WriteLine() を交えるなら List の .ForEach() を使うのが (開発効率的に) 早いだろうということで、メモリ効率を犠牲にしてこの形に。

数列のサイズが大きいか未知である場合は、メモリ効率を勘案し "データ変換 + 出力" を単位として遅延評価にする。その場合、最後を foreach {} 制御に置き換えるところなのだろうが、カーソル移動とブロックインデントが面倒なため、LINQ メソッドチェーンで次のように書いてしまう。ステートメント混じりである点とダミーデータを作って捨てるムダが美しくないが、デバッグなどではよく使う。

  .Select( x => { Console.WriteLine(x); return 1; } ).ToList();
Perl 5
map do {
  my $s = ($_ % 3 ? '' : 'Fizz') . ($_ % 5 ? '' : 'Buzz');

  print $s ? $s : $_, "\n";
}, (1 .. 30);

所要 3 分。同一内容の記述であるが、こちらの方がコーディングの所要時間が長くなった。

Perl は得意ではあるもののたまにしか使わないため、いかに簡便に書けるか条件判定と演算子との組み合わせ *2 を改めて意識する必要があり、また、map の対象をステートメントブロックにするか式にして手続を外に追い出すか試行錯誤したため、そこに時間を取られる。

Perl 5 (別解)
print map{(($s=($_%3?'':'Fizz').($_%5?'':'Buzz'))?$s:$_)."\n"}(1..30);

どれだけ短く書けるか競争なら、こうかな? 70 文字。読みにくい...

三項演算子を用いている部分をより短く書けないか *3 と久しぶりにリファレンスを参照してみて、 // 演算子や given 文といったものが Perl 5 に追加されていることを知った。given が文でなく式なら嬉しいのだが。

C# 9.0 (別解)
Enumerable.Range(1, 30)
  .Select( i => (i % 3 == 0 ? "Fizz" : "") + (i % 5 == 0 ? "Buzz" : "") is var s && s.Length > 0 ? s : "" + i )
  .ToList().ForEach( x => Console.WriteLine(x) );

標準解は素直な書き方だが、3, 5, 15 の倍数での場合分けと行数が多くなってしまうところに若干の負けた感が漂うため、別解も用意してみた。作成した Fizz/Buzz 文字列を (is var パターンマッチのちょっとした濫用により) 変数 s で受け、ワンラインの式としている。

あるいは次の通り。switch と () が増える分だけ長くなるかと思いきや同じコード長。ポイントはやはり、代入文を使わず、式の内部で変数 s を受けるというところ *4

Enumerable.Range(1, 30)
  .Select( i => ((i % 3 == 0 ? "Fizz" : "") + (i % 5 == 0 ? "Buzz" : "")) switch { "" => "" + i, var s => s } )
  .ToList().ForEach( x => Console.WriteLine(x) );

コード長 + 可読性 の観点からはこれが、実行速度 + 可読性の観点からは標準解が C# の最適解 *5 になるだろうか。


パラダイムオブジェクト指向から関数型プログラミングに移りつつあってプログラミング言語が乱立する時代でもあり、たまにこういう基礎問題に立ち返るのも悪くない。

関数型が志向されるようになると式をワンラインで記述することも許容されやすくなるだろう。ワンライナーが嫌われるのは、意味単位の異なる複数ステートメントを無理やり1行に収めようとするからだと思っている。

おまけ

C# 9.0 ("世界のナベアツ" バージョン)
using System.Text.RegularExpressions;

var dic = new [] {
  "/じゅう/にじゅう/さぁ~んじゅう/よんじゅう"   ,
  "/いち/に/さぁ~ん/し/ご/ろく/しち/はち/きゅう",
}
  .Select( str => 
            str.Split("/")
              .Select( (x, i) => new { Key = i, Value = x } )
              .ToDictionary( x => x.Key, x => x.Value ) )
  .ToList();

Enumerable.Range(1, 40)
  .Select( i => i % 3 == 0 || Regex.IsMatch($"{i}", "3") ? dic[0][i / 10] + dic[1][i % 10] : $"{i}" )
  .Append("オモロ~")
  .ToList().ForEach( x => Console.WriteLine(x) );
// 出力結果
// 
// 1
// 2
// さぁ~ん
// 4
// 5
// ろく
// ...
// 29
// さぁ~んじゅう
// さぁ~んじゅういち
// さぁ~んじゅうに
// さぁ~んじゅうさぁ~ん
// さぁ~んじゅうし
// さぁ~んじゅうご
// さぁ~んじゅうろく
// さぁ~んじゅうしち
// さぁ~んじゅうはち
// さぁ~んじゅうきゅう
// 40
// オモロ~

*1:にわかプログラマが適当に書いた、設計思想も可読性/保守性/拡張性もないコードが散在すると業務効率が低下し、DX (= 業務変革) や経済発展は却って阻害されると個人的には思うのだが、それを懸念する声はいまのところ聞こえてこない。自己流の業務プロセスにこだわり、パッケージよりもスクラッチ/アドオン開発を志向して出遅れたという日本企業の 2000 年ごろの ERP 導入の轍を踏む気がする。

*2:Perl では false のみならず undef, 空文字列, 0 も偽と判定されるため、これを利用して演算をうまく短絡表現できるか検討するのが面白いのだが、たいてい期待外れに終わる。

*3:A ? A : B の A が二度出現しないような短絡表現できる (例えば A ?: B のような) 演算子が導入されたら、代入による中間変数宣言が不要になり、(関数型プログラミング観点での) 表現力向上に資すると思う。

*4:LINQ to Entities でビジネスロジックをゴリゴリ書いていると、手続・文を用いずにいかに式のみで演算を記述するかという工夫を常にすることになるため、式内の変数受けは is var か switch 式を使う (あるいは .Select() メソッドを多段に連ねる) というのが定石として頭に入っている。残念ながら、パターンマッチは IL レイヤーで純粋関数ではない順次実行手続にコンパイルされる (= 式木 3.0 非対応) というのが .NET の仕様だと思われるため、is var も switch 式も LINQ to Entities では使えず LINQ to Object 限定になるのだが。

*5:結果をリストにするまでを 100 万回繰り返したときの所要時間は、標準解:別解 1:別解 2 = 625ms : 795ms : 835ms = 100%:127%:136% となった。オプティマイザ次第で揺らぐ要素が多々あるロジックであるため、実行速度の違いは無視できる水準ではある。

Training in Commercial Pilotage with FTD/FFS - Constant-Rate-and-Speed Climb / Descent, Lead Design for Level Off

羽田空港内某所で Flight Simulator による F/O 訓練。B737-Max で 2.0H。本日は N 教官。

5 ヶ月ぶりの F/O 訓練。ブランクが長い上に、この間イメトレをまったくやっていない。さすがに技量は低下しているだろうと覚悟して訓練に臨む。

Acceleration / Deceleration

f:id:Crayon:20210410151738j:plain:right:w320まずはいつもどおり RJTT RWY 34R から 6,000 ft 240 kt を目指す。きっとへたくそだろうと思っていたら、Take Off Climb は Pitch 20°、所定 A/S 165 kt, V/S 4,500 ft/min で 4,000 ft まで安定して昇る。4,000 ft で教官が中座し自主練習になると A/S は所定 +6 kt オーバー, V/S 500-1,000 ft/min くらいになりペースが狂い始め、6,000 ft Level Off も +7-8 kt オーバー。一回 Power Balance が整ってしまうと A/S と Alt が安定してしまい Power 調整が難しい。1kt = 1% Power で調整するという B737 初回操縦時にいただいた助言を遅まきながら思い出し、調整するもラスト 1,000 ft から Stabilize まで 3 分半もかかってしまった。まあまあ、こんなもんでしょ、と教官に言われ、ブランク明けのリハビリ科目 Acceleration / Deceleration へ。

240 kt から 200 kt へ落とす。やはり A/S がズレているままで Power Balance が整ってしまい調整に難航する。速度オーバーから指定諸元に合わせるのが難しい。また 200 kt くらいだと操作性は悪くならないと思いきや、予想外に操作性が悪い。何回か 240 kt と 200 kt を行き来した後、140 kt まで下げる。

操縦に慣れてきたのと、元々 Slow Flight が得意だったことがあり、操縦性が極めて悪いはずの 140 kt でもまあ安定してコントロールする。

Constant-Rate-and-Speed Climb / Descent

次に Constant-Rate-and-Speed Climb / Descent。240 kt を維持したまま 1,000 ft Climb / Descent を V/S Rate = 500 ft/min 維持、750 ft/min 維持、1,000 ft/min 維持 ... とやっていく (本日は 750 ft/min まで)。 まあまあ安定してできていると思ったのだが、教官曰く「では、計時してやってみましょう」。500 ft/min なら 2 分。2 分きっちりで到達できるかというタイムトライアル。500 ft/min ならば動きが穏やかなため「1 分経過時点で 400 ft 移動 = 100 ft 遅行だな」と冷静に考えている余裕があるし、Level Off もうまくいった (... が、HDG xxx へ転針してなどとストレスを掛けられたらグダグダになっていく可能性は大いにある)。

ここで初対面の教官へ交代してタイムトライアルを継続。自衛隊 C-1 哨戒機の現役パイロットらしい。Constant-Rate-and-Speed Climb / Descent を継続して訓練。この中で教わった手順は "本日の重要ポイント" の通り。また Idle Descent にしてもオーバースピードで定速になってしまう場合の Constant-Rate-and-Speed Descent のやり方として、一旦 Descent Rate 維持から離れ、Pitch を上げて A/S を殺してから、また Rate を維持する、という方法を教わった。

最後に振り返り。Climb / Descent の開始と Level Off のときの操作が決定的に弱い。これが粗いと Stabilize せず、長くかかる。技量を高く見せるコツは Pitch 操作の開始・終了の繊細さなのではないかと思った。

本日の重要ポイント

Climb / Descent の手順
  • Climb / Descent の開始と Level Off を繊細に Pitch 急操作厳禁!
  • Climb / Descent の初動は Power ではなく Pitch *1
  • 最初に Pitch 目標を意識し、そこに向けて緩やかに持っていく
  • Pitch の目標が定まってから A/S のトレンドを見て Power を入れる
Constant-Rate-and-Speed Climb / Descent の手順
  • VSI で所望 Climb / Descent Rate を確認し、その時の Pitch を以降ずっと維持する
  • Pitch による Energy Trade を常に行う
  • A/S を定期チェックし Energy Trade で調整できない分を Power で調整する
  • Constant-Rate-and-Speed Climb / Descent の A/S Allowance は ± 3 kt *2
その他 Tips
  • Power Balance が整ってしまうと調整が難しい
    • Energy Trade をうまく利用し、無駄な Power 調整をしない 調整は常に Pitch が先、Power が後
    • Power がズレたまま Level Flight で安定してしまった場合は 1 kt = 1% Power を目安に微調整する
  • Constant-Rate-and-Speed Descent で増速してしまったら、一旦 Descent Rate 維持から離れ、Pitch を上げて A/S 調整してから、また Rate を維持する

*1:レシプロ小型機の実機操縦経験者がまず Power を操作してしまうというのは Private Pilot のあるある失敗。

*2:Allowance を逸脱すると即 fail ではなく、収めようとする修正意識を無くすと fail。

Functional Programming w/ C# LINQ - with 初期化子の IQueryable<T> 対応版代替簡易記法

C# 9.0 / .NET 5.0 に長らく期待していたことの1つに record の with 初期化子がある。これは record の一部のプロパティを異なる値に置き換えるためのもので y = x with { Value = x.Value * 10 } と書くと x の Value プロパティだけを 10 倍にしたオブジェクトを新規生成して y に代入してくれる。

ところが .NET 5.0 で使ってみると、この with 初期化子が式木にならず、したがって LINQ to Entities で機能しない。with 初期化子は式のように見えてコンパイラ内部では .Clone() してから指定されたプロパティを上書きしているため、文 (手続) 扱いになっている。

この with 初期化子を式にしろと C# 開発チームに改良を提案するも却下される。一方で、C# 開発チームのメンバー曰く、式木になる x = new MyRecord { Value = 1 } というオブジェクト初期化子も 0 埋めデータで class / record の側を作ってからプロパティを1つ1つ上書き代入していると。おいおい同じ手順を踏んでいるのに片方が式になりもう片方が式にならないのは非対称でおかしいじゃないか、と食い下がったものの、醜い仕様であることを認めた (、でもたぶん彼らはもっと優先度の高い課題をたくさん抱えている) ため、議論はそこでおしまいにした。

しかし、with 初期化子は、本来、データ加工を頻繁に繰り返す LINQ to Entities でこそ活きるはずの生産性向上ツールだ。これがあると置換対象ではないプロパティに値代入する記述を省ける。オンメモリでオブジェクトを扱う LINQ to Object よりも DB を扱う LINQ to Entities の方がプロパティ (カラム) 数が断然多い。LINQ to Object ならば .Clone() をちょっと加工すれば with と同等のことが簡単に実現できる。with 初期化子が LINQ to Object でのみ有効というのは趣旨からいって本末転倒だ。

そこで LINQ to Entities で利用可能な代替手段を作ることに挑戦してみた *1

目標

LINQ to Entities (IQueryable<T>) で

  .Select( x => x with { Value = x.Value * 10 } ) 

と書いても、コンパイルエラー "error CS8849: An expression tree may not contain a with-expression." が出て機能しないため

  .SelectWith( x => new { Value = x.Value * 10 } ) 

と書くと同等の効果を得られるようにする。

with 初期化子の IQueryable<T> 対応版代替簡易記法

以前C# 8.0 以前用に with 初期化子相当となる .Clone() メソッドを定義した。これは LINQ to Object (IEnumerable<T>) にしか対応しておらず LINQ to Entities では機能しないが、まったく同じコンセプトを IQueryable<T> へ応用して、シーケンスの各オブジェクトの一部プロパティを置換する .SelectWith() という LINQ メソッドを定義する。

LINQ to Object を対象とした前回は実行効率の観点から式木にしたが、LINQ to Entities を対象とする今回はクエリプロバイダが解釈・実行可能な形での式木化が必須である。ポイントは以下のとおり。

  • 前回の .Clone() メソッド定義から引き継ぐコンセプト
    • ユーザは、オリジナルのオブジェクトのうち置換したいプロパティのみを含有するよう定義された匿名型オブジェクトを引数に指定する
    • オリジナルをクローンして新しいオブジェクトを生成したうえで、引数に与えられた匿名型のプロパティの名称・型がオリジナルのそれと一致していれば、匿名型オブジェクト側のインスタンス値を採用してプロパティへ代入して返す
    • オリジナルと名称・型が一致していないプロパティを匿名型に指定した場合は、ランタイムエラーとする *2
    • ただし、オリジナル側が匿名オブジェクト側を Nullable 形式にした型になっている、というプロパティはその差異を許容して Nullable 型にキャストして代入する *3
    • 与えられる引数は辞書にキャッシュしておき、同じものが指定された場合は辞書を検索する
  • 今回の進化
    • Null 参照許容型を有効化した記述にしている *4
    • 引数で与えるのは、匿名型オブジェクトそのものではなく、オリジナルのオブジェクトから置換候補となる匿名型オブジェクトを導出するラムダ式

この「式木を用いて、オリジナルのオブジェクトとそれを入力としてラムダ式で導出する置換候補匿名型オブジェクトの双方を参照しつつ、新しいオブジェクトをワンショットで初期化生成する」というところが最大のポイントであり、難所となる *5

#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Runtime.CompilerServices;
using static System.Linq.Expressions.Expression;

public static partial class ExtensionIQueryable {
  /// メンバーバインド・ラムダの辞書
  private static Dictionary<Expression, Expression> DicMemberBind { get; set; } = new();

  ///
  /// 一部のプロパティを置き換える Select
  /// C# 9 with 式が 式木 / IQueryable<T> 非対応のため、代替としての IQueryable<T> 版 Select( x => x with { Prop = ... } )
  ///
  public static IQueryable<T> SelectWith<T, U>(this IQueryable<T> source, Expression<Func<T, U>> lambdaReplace) where T : notnull, new() where U : notnull {
    var original = Parameter(typeof(T), "original");
    var subst    = Invoke(lambdaReplace, original);
    var propT    = typeof(T).GetProperties();
    var propU    = typeof(U).GetProperties();
    var mismatch =
      propU
        .GroupJoin(propT,
                   l      => l.Name,
                   r      => r.Name,
                   (l, r) => new { SubstPropName = l.Name, IsMismatched = ! r.Any() })
          .Where( x => x.IsMismatched )
            .Select( x => x.SubstPropName )
              .ToList();

    if (mismatch.Any()) // 存在しないプロパティの置換を指定された場合
      throw new ArgumentException($"ラムダ式の返り値に置換候補として指定されたオブジェクトの次のプロパティは置換対象に存在しないプロパティです。" +
                                    $"-- {string.Join(", ", mismatch)}");

    if (! DicMemberBind.TryGetValue(lambdaReplace, out var lambda)) {
      var bind =
        propT
          .GroupJoin(propU,
                     l      => l.Name,
                     r      => r.Name,
                     (l, r) => new {
                       Prop  = typeof(T).GetMember(l.Name)[0],
                       Value = (l, r.FirstOrDefault()) switch {
                         (    _, null )                                       => (Expression)        PropertyOrField(original, l.Name),                  // t.Prop =                            t .Prop
                         (var o, var s) when o.PropertyType == s.PropertyType => (Expression)        PropertyOrField(subst   , l.Name),                  // t.Prop =              lambdaReplace(t).Prop
                         (var o, var s) when o.IsNullableOf(s)                => (Expression)Convert(PropertyOrField(subst   , l.Name), l.PropertyType), // t.Prop = (Nullable<>) lambdaReplace(t).Prop
                         (var o, var s)                                       =>
                           throw new ArgumentException($"{o.PropertyType.FullName} 型のプロパティを {s.PropertyType.FullName} 型で置き換えようとしました。"),
                       },
                     })
            .Select( mb => Bind(mb.Prop, mb.Value) ); // IEnumerable<MemberBinding>

      DicMemberBind.Add(lambdaReplace, lambda = Lambda<Func<T, T>>(MemberInit(New(typeof(T)), bind), original));
    }

    return source.Select( (Expression<Func<T, T>>)lambda ); // 辞書に入っている値の型は決まっており常にキャスト可能
  }

  /// あるプロパティの型が別のプロパティの Nullable<> 型であるか判定する
  [MethodImpl(MethodImplOptions.AggressiveInlining)]
  private static bool IsNullableOf(this PropertyInfo o, PropertyInfo s) =>
    (o.PropertyType, s.PropertyType) switch {
      (var ot,      _) when ot.IsGenericType                != true               => false,
      (var ot,      _) when ot.GetGenericTypeDefinition()   != typeof(Nullable<>) => false,
      (var ot, var st) when ot.GenericTypeArguments.First() != st                 => false,
      (     _,      _)                                                            => true , // T = U? の場合
    };
}

IQueryable<T> のラムダ式評価時 (ランタイムでの SQL コンパイル時) に、

  1. 引数に与えた「オリジナルから置換候補となる匿名型オブジェクトを導出するラムダ式」を Expression.Invoke() で評価して置換候補オブジェクトを求め、
  2. 入力と同じ型のオブジェクトを Expression.New() で新規作成し、
  3. オリジナルのプロパティと置換候補のプロパティとを LINQ to Object の .GroupJoin() メソッドを用いて比較して名前と型をチェックし、
  4. 必要としている側のインスタンス値を採用するようにプロパティ・アクセスを Expression.PropertyOrField() で選択する MemberBinding を構築し、
  5. この MemberBinding を使って新規作成オブジェクトのメンバーを Expression.MemberInit() で初期化して返す、というラムダ式を導出し、
  6. このラムダ式によって求めるべきオブジェクトを生成して出力する

という手順を踏んでいる。


このように使う。

var context = new MyDbContext(); // DB アクセス用クラス (ユーザ定義クラス)
var query   =
  context.MarketData
    .Where( x => x.TickerCode == "NKY" && x.Indicator == "Volume" )
      .Take(5)
        .SelectWith( x => new { Value = Math.Floor(x.Value * 10.0 ?? 0.0) } );

Console.WriteLine(query.ToQueryString());  // DB への発行 SQL 文を表示
query.Display();                           // SQL 実行結果をクライアント側に持ってきて表示 (ユーザ定義メソッド)


出力結果は下記のとおり。(裏で動かしている DB は PostgreSQL。)

-- @__p_0='5'
SELECT b.tickercode AS "TickerCode", b.indicator AS "Indicator", b.basedate AS "BaseDate", floor(COALESCE(b.value * 10.0, 0.0)) AS "Value"
FROM marcketdata AS b
WHERE (b.tickercode = 'NKY') AND (b.indicator = 'Price')
LIMIT @__p_0
MarketData { TickerCode = NKY, Indicator = Volume, BaseDate = 07/01/2011 00:00:00, Value = 1115 }
MarketData { TickerCode = NKY, Indicator = Volume, BaseDate = 07/04/2011 00:00:00, Value = 1313 }
MarketData { TickerCode = NKY, Indicator = Volume, BaseDate = 07/05/2011 00:00:00, Value = 1325 }
MarketData { TickerCode = NKY, Indicator = Volume, BaseDate = 07/06/2011 00:00:00, Value = 1377 }
MarketData { TickerCode = NKY, Indicator = Volume, BaseDate = 07/07/2011 00:00:00, Value = 1331 }

オリジナルの Value は double? 型 (not null 制約なし) で 111.59, 131.38, 132.58 ... と小数点以下第2位までの値が格納されている。これが .SelectWith() メソッドで 10 倍されて小数点以下切り捨てとなって出力される。ポイントは C# のメソッド Math.Floor() や演算子 ?? がきちんと DB 関数の floor() や coalesce() に翻訳されていることと、double 入力・double 出力である Math.Floor() が、引数においては double? 型の Value を ?? を用いて明示的に double 型に強制する必要がある一方、Value への代入においては返値を明示的に double? へ型変換する必要はないということ。

また、プロパティの名や型が合致しないと以下のようなランタイムエラーとなる。

// ランタイムエラー「double? 型のプロパティを string 型で置換しようとしました」となる
.SelectWith( x => new { Value = "" + Math.Floor(x.Value * 10.0 ?? 0.0) } ) 

// ランタイムエラー「ラムダ式の返り値に置換候補として指定されたオブジェクトの次のプロパティは置換対象に存在しないプロパティです -- Value2」となる
.SelectWith( x => new { Value2 = Math.Floor(x.Value * 10.0 ?? 0.0) } )

代替簡易記法の限界

定義した .SelectWith() は

var multiplier = 10;

DataRecords
  .Select( x => x with { Value = x.Value * multiplier } );

のように、生成したいオブジェクト自体のプロパティやシーケンス内で不変の変数や定数を参照して取り込む分には問題がないが、

// 例 1. ブジェクトを新規生成してそのシーケンスを返すにあたり、別タイプのシーケンス Calendar のプロパティを取り込みたい
Calendar
  .Select( c => new DataRecord() with { Date = c.Date } );

// 例 2. オブジェクトをプロパティ修正したシーケンスを返すにあたり、別タイプのシーケンス Calendar のプロパティを取り込みたい
Calendar
  .Join(DataRecords,
        l      => l.Date,
        r      => r.Date,
        (l, r) => r with { IsBusinessDay = l.IsBusinessDay });

という (シーケンス内で変化する) 外部変数を巻き込むクロージャー的な使い方には対応できない。

with に絡む部分のラムダ式を返す式木を作成すればクロージャー的な用途にも対応できる *6 が、巻き込む外部変数の数や位置に応じてラムダ式を作成しなければならないため、汎用性がない。やはりコンパイラが with を手続ではなく式として解釈して MemberBinding & MemberInit して IQueryable 対応してくれるのが望ましい。

*1:LINQ to Object では動くが LINQ to Entities では動かないというありがちな事象で苦しむこと 1 ヶ月。さきの .LeftJoin() 簡易記法を定義する際に解決法を発見した。LINQ to Object で動き LINQ to Entities で動かなかったのは、Expression.Block() を用いて式木構築してしまったため。

*2:置換候補となる匿名型のプロパティをミススペルしている可能性を警告するため。

*3:double? Value というプロパティに対する代入式では、new { Value = 1.0 } が NG となり、new { Value = (double?) 1.0 } と冗長に記述しなければならなくなるため。

*4:より厳しい型チェックでも通るように記述した。

*5:何もないところから構築するのはかなり試行錯誤を要する難しいパズルだったが、できたものを眺めると大したことないように見える。コロンブスの卵。

*6:例1 は実現可能であることを確認済み。

Functional Programming w/ C# LINQ - 左結合 Left Join の簡易記法

C# 9.0, .NET 5.0, .Entity Framework Core 5.0 を使って数ヶ月。当初抱いていた LINQ to Entities の機能向上への期待は打ち砕かれたが、それでも EF 6.0 や EF Core 2.0 に比べたら使いやすくなっているような気がする。本格的に使うにあたり、少し工夫をする。

左結合 Left Join の簡易記法 (syntax sugar)

LINQ to Entities での左結合は .GroupJoin() 1つでは済まず、 .GroupJoin() と .SelectMany() の合わせ技になるのだが、読みづらいし、書きづらい。

TableA
  .GroupJoin(TableB,
             l      => new { JoinKey1 = l.JoinKey1, ... },
             r      => new { JoinKey1 = r.JoinKey1, ... },
             (l, r) => new { Left = l, Right = r })
    .SelectMany(x => x.Right.DefaultIfEmpty(),    // .SelectMany() を駆り出した2段構成が美しくない
                (l, r) => new ResultRecord {
                  Prop1 = l.Left.Prop1,               // 結果作成時に左だけ .Left プロパティ・アクセスする非対称性が美しくない
                  Prop2 = l.Left.Prop2,
                  Prop3 = r == null ? null : r.Prop3, // r?.Prop3 は式木非対応
                } );

内部結合 .Join() と違って左結合 .GroupJoin() は第5引数の resultSelector ラムダ式内で右テーブル・レコードを要素ではなくシーケンスで扱うことになるため、プロパティ・アクセスが左右非対称となって美しくなく、また、ハンドリングがけっこう面倒である。右側テーブル側に結合対象がなかったレコードは null になるわけで、.Any(), .FirstOrDefault(), null 伝搬演算子 (?.), null 合体演算子 (??), 三項演算子 (? :) を多用する *1 ことになる上に、LINQ to Object では機能するものの式木にできず、LINQ to Entities で機能させるためにさらに冗長に書き換えなければならない表現 *2*3 も頻繁にある。今後、null 許容参照型を有効にすること (#nullable enable) が推奨されるようになると、null に関してエラーや警告が多発し、この null ハンドリングと書き換え問題がさらに面倒になるのは間違いない。


そこで、この際、Syntax Sugar として左結合の簡略記法 .LeftJoin() を定義してみることにする。

#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using static System.Linq.Expressions.Expression;

public static partial class ExtensionIQueryable {
  /// 2要素参照ラムダの辞書
  private static Dictionary<Expression, Expression> DicTwoFactor { get; set; } = new();

  ///
  /// Left Join 簡略記法用ヘルパーレコード
  ///
  private record LeftJoinHelperRecord<T, U> where T : notnull, new() where U : new() {
    public T  Left  { get; init; } = new T();
    public U? Right { get; init; } = default;
  }

  ///
  /// Left Join 簡略記法
  ///
  public static IQueryable<V> LeftJoin<T, U, V>(this IQueryable<T> leftSource, IQueryable<U> rightSource,
                                                Expression<Func<T, object>> leftKeySelector, Expression<Func<U, object>> rightKeySelector,
                                                Expression<Func<T, U?, V>> resultGenerator)
    where T : notnull, new() where U : new() where V : class {
      if (! DicTwoFactor.TryGetValue(resultGenerator, out var lambda)) {
        var typeH   = typeof(LeftJoinHelperRecord<T, U>);
        var objectH = Parameter(typeH, "LeftJoinHelperRecord<T, U>");

        DicTwoFactor
          .Add(resultGenerator,
               lambda = Lambda<Func<LeftJoinHelperRecord<T, U>, V>>(
                 Invoke(resultGenerator, PropertyOrField(objectH, "Left"), PropertyOrField(objectH, "Right")),
                 objectH));
      }

      var lambdaGenerator = (Expression<Func<LeftJoinHelperRecord<T, U>, V>>)lambda; // 辞書に入っている値の型は決まっており常にキャスト可能

      return
        leftSource
          .GroupJoin(rightSource, leftKeySelector, rightKeySelector, (l, r) => new { Left = l, Right = r })
            .SelectMany(x => x.Right.DefaultIfEmpty(), (l, r) => new LeftJoinHelperRecord<T, U> { Left = l.Left, Right = r })
              .Select(lambdaGenerator); // (l, r) => new { l.Prop ... r.Prop ... }
    }
}


こんな使い方をする。

var context = new MyDbContext(); // DB アクセス用クラス (ユーザ定義クラス)
var query   =
  context.MarketData
    .Where( x => x.TickerCode == "NKY" && x.Indicator == "Price" )
      .Take(5)
        .LeftJoin(context.TickerMaster,
          l      => l.TickerCode,
          r      => r.TickerCode,
          (l, r) => new {
            TickerCode = l.TickerCode,
            Indicator  = l.Indicator,
            BaseDate   = l.BaseDate,
            Value      = l.Value,
            Note       = r.Note, // 1要素の結合 (r != null が既知の場合 ... マスタを左結合してタグ付けをするときなど)
            Relation   = r,      // 結合対象の右レコード全体への参照
          });

Console.WriteLine(query.ToQueryString());  // DB への発行 SQL 文を表示
query.Display();                           // SQL 実行結果をクライアント側に持ってきて表示 (ユーザ定義メソッド)


出力結果は下記のとおり。(裏で動かしている DB は PostgreSQL。)

Note のように結合対象右レコード内の1要素を取り込むこともできるし、(Entity Data Model 上は) Relation のように結合対象の右レコード全体への参照として保持することもできる *4。発行 SQL 文をロガーを通さずに .ToQueryString() として参照できるようになったのは EF Core 5.0 での機能向上の1つ。

-- @__p_0='5'
SELECT t.tickercode AS "TickerCode", t.indicator AS "Indicator", t.basedate AS "BaseDate",
       t.value AS "Value", b0.note AS "Note", b0.tickercode
FROM (
    SELECT b.tickercode, b.indicator, b.basedate, b.value
    FROM marketdata AS b
    WHERE (b.tickercode = 'NKY') AND (b.indicator = 'Price')
    LIMIT @__p_0
) AS t
LEFT JOIN tickermaster AS b0 ON t.tickercode = b0.tickercode
{ TickerCode = NKY, Indicator = Price, BaseDate = 04/03/2001 00:00:00, Value = 13124.47, Note = 日経 225, Relation = TickerMaster { TickerCode = NKY, Note = 日経 225 } }
{ TickerCode = NKY, Indicator = Price, BaseDate = 04/02/2001 00:00:00, Value = 12937.86, Note = 日経 225, Relation = TickerMaster { TickerCode = NKY, Note = 日経 225 } }
{ TickerCode = NKY, Indicator = Price, BaseDate = 03/30/2001 00:00:00, Value = 12999.70, Note = 日経 225, Relation = TickerMaster { TickerCode = NKY, Note = 日経 225 } }
{ TickerCode = NKY, Indicator = Price, BaseDate = 03/29/2001 00:00:00, Value = 13072.36, Note = 日経 225, Relation = TickerMaster { TickerCode = NKY, Note = 日経 225 } }
{ TickerCode = NKY, Indicator = Price, BaseDate = 03/28/2001 00:00:00, Value = 13765.51, Note = 日経 225, Relation = TickerMaster { TickerCode = NKY, Note = 日経 225 } }

*1:null 合体演算子 (??) は式木になるが null 伝搬演算子 (?.) はならない。そこで .Any() や三項演算子 (? :) の出番が増え、冗長になる。

*2:状態遷移を伴う switch 式を式木化できないのは仕方ないが、等価の式に変換できるはずの null 伝搬演算子 (?.) と with 式が C# コンパイラ内部で文 (手続) 扱いされ、式木化されないのは間抜け。

*3:with 式の式木化は C# 開発チームに提案したものの却下された。が、最近、LINQ to Entities で機能する .Select( x => x with { Prop1 = ... } ) の代替簡易記法を定義できたため、後日アップする。

*4:画面出力してフラットにすると、連想配列を内包するオブジェクトを JSON シリアライズしたかのような見映えになる。