30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

ツリー図を簡単に作れるウェブアプリを作った話と、抽象的思考力の話

他の図形も作れるようにしたい気持ちはあるが、今はツリー図だけ。

shape-painter.numb86.net

ソースも公開している。

github.com

作った動機

単純に、自分が欲しかった。
図形を作成するツールは、もっと高機能で高品質なものが、他にたくさん存在する。だがそういったツールは、機能が多すぎて初見では分かりづらかったり、自分が欲しい図を作るためには細かい調整が必要だったりする。

このアプリでは、カスタマイズ性を犠牲にして、とにかくシンプルで直感的に操作できるようにすることを、基本的な方針とした。
そして、ユーザが細かい調整を出来ない代わりに、頻出の図形に対してテンプレートを用意しておけば、簡単な操作で「それっぽい」画像を作成できるのではないかと考えた。
現時点ではツリー図しか用意していないので、その目的はあまり達成できていないのだが。

最初の図形としてツリー図を選んだのは、思い付いた図形のなかで一番難しそうだったから。後述するが、実際、難しかった。

自分にとって需要がありそうだったから、というのも理由のひとつ。
プログラミングの世界では「木」という概念は頻繁に出てくる。

先日公開した以下の記事のグラフもこのアプリを使って描いたが、npm パッケージの依存構造は、まさに木である。

numb86-tech.hatenablog.com

DOM や仮想 DOM もそうだし、抽象構文木もその名の通り木である。木構造を簡単に描画できるツールがあれば、何かと便利なのではないかと考えた。

このアプリそのものを作った理由に戻ると、フロントエンドの腕試しという意味合いもある。
「何かを作ることが一番勉強になる」とはよく言われるし、私もそう思うが、フロントエンドに専念してアプリを作るのは意外と難しい。
インタラクティブ性のない完全にスタティックなサイトなら、わざわざ React や TypeScript を使う必要性がない。
かといってバックエンドも用意するとなると、「フロントエンドの腕試し」という趣旨から大きく逸脱してしまう。何より、公開後のメンテナンスや運用が大変になってしまう。
フロントエンドだけで完結するこのアプリは、題材としてちょうどよかったのだ。

振り返り

React などのフロントエンドの腕試しとして始めたのだが、そこではそれほど苦労しなかった。
それよりも、とにかくロジックを考えるのが大変だった。

ここでいうロジックとは、ツリーの各ノードをどこに配置するかを計算することなのだが、ここに一番時間が掛かった気がする。
ノードとノードが重なってはいけないし、全体として見た時に不自然な形になってはいけない。
試行錯誤の結果、複雑な形状でも、なんとかそれっぽく表示できるようになった。

f:id:numb_86:20200530140912p:plain

修正に修正を加えているので、全体の処理の流れはとても綺麗とは言い難いし、恐らく無駄も多いとは思うが。

ロジックやアルゴリズムを考えるのが本当に苦手なんだということを痛感した。
その原因のひとつが、抽象的に思考する能力の低さ。
抽象的なものを、抽象的なまま扱えない。具体的なものに落とし込まないと、思考することができない。
極端な例を挙げると、x + 1という処理がどんな結果をもたらすのか、イメージできない。x12を当てはめて考えることでようやく、処理の流れや規則性を掴むことができる。
もちろんこれくらい単純な例なら具体化しなくても済むのだが、もう少し複雑な処理になると、途端に厳しくなる。

抽象的思考力が低いせいで、ロジックを考えるのに時間がかかる。そして何とか考えついても、それをコードで表現するのにまた時間がかかる。
x12のときだけ処理できればいいのではなく、xにどんな数値が入っても処理できなければならない。そういうプログラムを書くためにはやっぱり、抽象的に物事を考える能力が必要になる。

抽象的思考力の低さは、プログラマとして致命的な欠陥だと思う。

ポインタを使うプログラミングは今日書かれるコードの90%には必要とならず、製品コードにおいてははなはだ危険なものであるということは素直に認める。その通りだ。そして関数プログラミングは実務ではほとんど使われていない。それも認める。
しかしそれでも、最もエキサイティングなプログラミング仕事ではこれらは重要なものなのだ。
(中略)
しかしポインタと再帰の明らかな重要性以上に重要なのは、これらの学習から得られる精神的な柔軟さと、これらを教えている授業からふるい落とされないために必要な精神的態度が、大きなシステムを構築する上で欠かせないということだ。ポインタと再帰には、ある種の推論力、抽象的思考力、そして何よりも問題を同時に複数の抽象レベルで見るという能力が要求される。そしてポインタと再帰を理解できる能力は、優れたプログラマになるための能力と直接的に相関している。

Javaスクールの危険 - The Joel on Software Translation Project

私は「優れたプログラマ」になりたいし、「エキサイティングなプログラミング仕事」をしたい。
この記事の趣旨としては Scheme や C を使って再帰やポインタを学べということだと思うが、それらの言語を使っていけば抽象的思考力が上がるのだろうか?

もうひとつ「プログラマとして致命的では?」と感じたのは、英語力の低さ。
プログラミングにおいて命名は非常に重要だが、英語が苦手すぎて、適切な名前が思い付かない。単語レベルなら自動翻訳でどうにかなるが、一定以上複雑なことを表現しようとすると、かなり厳しい。コメントを書くのにも時間がかかる。
保守性を考えればコミットメッセージも重要だと思うが、これもやっぱり時間がかかる。というか、「そんなことに頭を悩ませていないでさっさとコーディングを進めたい」という気持ちになり、ついいい加減なメッセージになってしまう。

いろいろと書いたが、曲がりなりにも公開まで持っていったことは褒めたい。

今後の運用について

フロントエンド技術の「砂場」として使っていきたい。
TypeScript の型付けはまだまだ改善の余地がある。anyで逃げたところも多々あるし、実践のいい機会になると思う。
パフォーマンスもほとんど考慮していない状態なので、いろいろと試せると思う。

もちろんツリー図以外の図形も作りたいのだが、他に学びたいものや作りたいものがあるので、それらとの兼ね合いが難しい。

package.json の resolutions を使って依存パッケージのバージョンを指定する

package.jsonresolutionsフィールドを使うことで、依存ツリーの深い部分にあるパッケージのバージョンを固定することが可能になる。
現在のところ Yarn でのみ使える機能だが、サードパーティが公開しているライブラリを使うことで npm でも使えるようになる。

動作確認に使った npm のバージョンは6.14.5。Yarn は1.22.4
後述するnpm-force-resolutionsについては、0.0.3を使っている。

npm や Yarn でパッケージをインストールすると、指定したパッケージだけでなく、そのパッケージが依存しているパッケージもインストールされる。
そうしてインストールされたパッケージが他のパッケージに依存していれば、そのパッケージもインストールされ、それが繰り返されていく。
この仕組みについては、以下の記事で詳しく触れた。

numb86-tech.hatenablog.com

この際、インストールされる依存パッケージのバージョンは、^1.0.0のように^をつけた形で指定されていることが多い。
つまり、バージョンを厳密に指定することはできず、インストールするタイミングによってどのバージョンがインストールされるかが決まる。
そして、インストールしたタイミングでpackage-lock.jsonyarn.lockに書き込まれ、それ以降はそこに書き込まれたバージョンがインストールされることになる。

この仕組みだと、何らかの理由で依存パッケージのバージョンを指定したいときに、問題となる。

reactを例にして説明する。

まず、reactをインストールする。

$ yarn add react

記事執筆時点での最新バージョンは16.13.1なので、そのバージョンのreactがインストールされる。
そして、reactが依存しているパッケージもインストールされる。
prop-typesもそのひとつ。reactpackage.jsondependenciesprop-types@^15.6.2と指定されているので、この時点での15.xの最新バージョンである15.7.2がインストールされる。
yarn.lockにもそのように記録されるため、これ以降$ yarnを実行すると、yarn.lockの内容に基づいてprop-types@15.7.2がインストールされる。

prop-types@^15.6.2:
  version "15.7.2"
  resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
  integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
  dependencies:
    loose-envify "^1.4.0"
    object-assign "^4.1.1"
    react-is "^16.8.1"

しかしその後、prop-types@15.7.2に脆弱性が見つかったと仮定する(あくまでも例であって、現在のところそういった話はない)。
取り急ぎ15.7.3にバージョンアップするか、15.6に戻すように、アナウンスされた。

しかし、package.jsondependenciesで指定しているのはreactのみであり、prop-typesのバージョンを指定することはできない。

最も望ましいのは、reactが対応を行い、prop-types@^15.7.3に依存したバージョンをリリースしてくれることである。そうすれば、単にreactをバージョンアップすれば済む。
しかし、reactのように開発が活発なパッケージならともかく、メンテナンスが行き届いていないパッケージでは、対応が遅れる可能性は十分にある。

$ yarn add prop-types@15.6のように、明示的にprop-typesをインストールするという方法も考えられるが、これは上手くいかない。
この場合、既にインストールされているprop-types@15.7.2とは別に15.6.xがインストールされるだけであり、prop-types@15.7.2は引き続き使われ続ける。
そもそも、いくら脆弱性対応のためとはいえ、直接依存しているわけではないパッケージをpackage.jsondependenciesdevDependenciesに記述するのは望ましくない。

このような状況で使えるのが、resolutionsフィールドである。
このフィールドで、バージョンを固定したい依存パッケージの名前とバージョンを指定する。

  "resolutions": {
    "prop-types": "~15.6.0"
  }

このようにしたうえで$ yarnを実行すると、15.6.xの最新バージョンのprop-typesがインストールされる。

yarn.lockにもそれが反映されている。

prop-types@^15.6.2, prop-types@~15.6.0:
  version "15.6.2"
  resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.2.tgz#05d5ca77b4453e985d60fc7ff8c859094a497102"
  integrity sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==
  dependencies:
    loose-envify "^1.3.1"
    object-assign "^4.1.1"

この仕組みは Yarn でのみ利用可能で、今のところ npm には実装されていない。
だがnpm-force-resolutionsというライブラリを使うことで、npm でもresolutionsに基づいたインストールを行えるようになる。

github.com

まず普通にreactをインストールすると、当然、prop-types@15.7.2がインストールされる。

$ npm ls prop-types
└─┬ react@16.13.1
  └── prop-types@15.7.2
    "prop-types": {
      "version": "15.7.2",
      "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz",
      "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==",
      "requires": {
        "loose-envify": "^1.4.0",
        "object-assign": "^4.1.1",
        "react-is": "^16.8.1"
      }
    },

次に、package.jsonresolutionsを指定する。そしてscripts.preinstallを使って、インストールが行われる前にnpm-force-resolutionsが実行されるようにしておく。

  "resolutions": {
    "prop-types": "~15.6.0"
  },
  "scripts": {
    "preinstall": "npx npm-force-resolutions"
  }

この状態で$ npm iを実行すると、Yarn のときと同じように、15.6.xの最新バージョンのprop-typesがインストールされる。

$ npm ls prop-types
└─┬ react@16.13.1
  └── prop-types@15.6.2
        "prop-types": {
          "version": "15.6.2",
          "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz",
          "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==",
          "requires": {
            "loose-envify": "^1.3.1",
            "object-assign": "^4.1.1"
          }
        }

Yarn のときとの違いは、インストールされる場所。
Yarn ではnode_modules/prop-typesとしてインストールされたが、npm ではnode_modules/react/node_modules/prop-types/としてインストールされる。

依存パッケージにバージョンのコンフリクトが発生した場合

これも詳細は以下の記事に書いたのだが、複数のパッケージから依存されているパッケージの場合、それぞれに異なるバージョンが指定されていることがある。

numb86-tech.hatenablog.com

例えばbabel-loaderstyle-loaderはどちらもloader-utilsというパッケージに依存しているが、バージョンはそれぞれ^1.4.0^2.0.0を指定しており、この 2 つは両立しない。

babel-loader@^8.1.0:
  version "8.1.0"
  resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-8.1.0.tgz#c611d5112bd5209abe8b9fa84c3e4da25275f1c3"
  integrity sha512-7q7nC1tYOrqvUrN3LQK4GwSk/TQorZSOlO9C+RZDZpODgyN4ZlCqE5q9cDsyWOliN+aU9B4JX01xK9eJXowJLw==
  dependencies:
    find-cache-dir "^2.1.0"
    loader-utils "^1.4.0"
    mkdirp "^0.5.3"
    pify "^4.0.1"
    schema-utils "^2.6.5"
style-loader@^1.2.1:
  version "1.2.1"
  resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-1.2.1.tgz#c5cbbfbf1170d076cfdd86e0109c5bba114baa1a"
  integrity sha512-ByHSTQvHLkWE9Ir5+lGbVOXhxX10fbprhLvdg96wedFZb4NDekDPxVKv5Fwmio+QcMlkkNfuK+5W1peQ5CUhZg==
  dependencies:
    loader-utils "^2.0.0"
    schema-utils "^2.6.6"

こういった場合、両方のバージョンがインストールされ、ロックファイルにもそのように記録される。

loader-utils@^1.4.0:
  version "1.4.0"
  resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.4.0.tgz#c579b5e34cb34b1a74edc6c1fb36bfa371d5a613"
  integrity sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==
  dependencies:
    big.js "^5.2.2"
    emojis-list "^3.0.0"
    json5 "^1.0.1"

loader-utils@^2.0.0:
  version "2.0.0"
  resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.0.tgz#e4cace5b816d425a166b5f097e10cd12b36064b0"
  integrity sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==
  dependencies:
    big.js "^5.2.2"
    emojis-list "^3.0.0"
    json5 "^2.1.2"

このようなケースにおいてresolutionsで特定のバージョンを指定した場合、どのように処理されるだろうか。

まずは Yarn から見ていく。
今回は^2.0.0を指定してみる。

  "resolutions": {
    "loader-utils": "^2.0.0"
  }

この状態で$ yarnを実行すると、2.xの最新バージョンである2.0.0がインストールされ、1.4.0は削除された。

loader-utils@^1.4.0, loader-utils@^2.0.0:
  version "2.0.0"
  resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.0.tgz#e4cace5b816d425a166b5f097e10cd12b36064b0"
  integrity sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==
  dependencies:
    big.js "^5.2.2"
    emojis-list "^3.0.0"
    json5 "^2.1.2"

つまり、resolutionsフィールドで指定したバージョンに統一され、それ以外のバージョンは削除されるということである。

次に、npm-force-resolutionsの挙動を見ていく。
babel-loaderstyle-loaderをインストールし直して、2 種類のloader-utilsがインストールされていることを確認する。

$ npm ls loader-utils
├─┬ babel-loader@8.1.0
│ └── loader-utils@1.4.0
└─┬ style-loader@1.2.1
  └── loader-utils@2.0.0

prop-typesのときと同じ要領で、loader-utilsのバージョンを指定する。

  "resolutions": {
    "loader-utils": "^2.0.0"
  },
  "scripts": {
    "preinstall": "npx npm-force-resolutions"
  }

この状態で、$ npm iを実行する。
そうするとやはり同じように、2.xの最新バージョンで統一されている。

$ npm ls loader-utils
├─┬ babel-loader@8.1.0
│ └── loader-utils@2.0.0  invalid
└─┬ style-loader@1.2.1
  └── loader-utils@2.0.0

prop-typesのときと同様、インストールされる場所が、Yarn とは異なる。
Yarn のときはnode_modules直下にloader-utils@2.0.0がインストールされたが、npm-force-resolutionsではnode_modules/style-loader/node_modulesnode_modules/babel-loader/node_modulesにそれぞれ、loader-utils@2.0.0がインストールされる。

なお、今回は検証のためにこのような処理を行ったが、babel-loaderが依存しているloader-utils^1.4.0がなくなってしまい、動作が保証されなくなるため、このような処理は行うべきではない。

参考資料