こんにちは!
カヤック面白プロデュース事業部のおばらです。
本記事は『JS体操』第2問「どちらの画像が横長かを比較する」の解説記事です。
「正攻法」「ハック部門」の解説に引き続き、「チート部門」の解説をお送りします。
第2問「どちらの画像が横長かを比較する」 hubspot.kayac.com
第2問 解説「正攻法」「ハック部門」 techblog.kayac.com
JS体操とは?
『JS体操』とはカヤックが主催する JavaScript のコードゴルフ大会です。詳細は以下を御覧ください。
第2問の詳細
https://hubspot.kayac.com/js-taiso-002
もしまだ挑戦できていなかった!という方はぜひ。
目次
- JS体操とは?
- 第2問の詳細
- チート部門とは?
- チート部門① 「正解」「不正解」をチェックする処理を上書きする
- チート部門② 文字列を取得・整形する処理を上書きする
- チート部門③ 文字数を表示する処理を上書きする
- まとめ
- お知らせ
チート部門とは?
第2問は(第1問以上に)いろんなアプローチで解くことができました。
それぞれ、
- 「正攻法」 真面目に画像の縦横比を比較する方法
- 「ハック部門」 テストの穴を突く方法
- 「チート部門」 不正解を正解にしたり、文字数をごまかしたりする、ズルい方法
です。
「チート部門」はしょうもないズルい方法ではあるのですが、JavaScript のいろんな知識が必要で、頭の体操にももってこい。 本記事ではそんな「チート部門」の3パターンを真面目に解説します。
チート部門① 「正解」「不正解」をチェックする処理を上書きする
console.assert() のおさらい
第2問では console.assert()
で正解か不正解か、テストに合格しているかをチェックしています。第1問は目視で正解か不正解を判断する問題だったので console.assert()
は第2問で初登場です。
まずは console.assert()
の仕様を簡単に振り返りましょう。
- 第1引数が
truthy
である場合は何もしない - 第1引数が
falsy
だとエラーが投げられる(その際、第2引数以降の情報がエラーメッセージに含まれる)
ざっくりいうと、
ある(条件)式を、「これは true になるはずだ!」と断言(assert)したうえで、
もしそれが false
、つまり間違っていたら「え、違うじゃん!」ということでエラーが投げつけてくる、それが console.assert()
です。
console.assert()
で true
なはずだ!と断言(assert)したすべてのテストを、
有言実行、断言(assert)した通りちゃんと全部 true
にするのが、前回の記事でご紹介した「正攻法」と「ハック部門」です。
でもちょっと待ってください。
true
だろうが false
だろうが、エラーを投げられないように口封じをしてしまえば良い、、、という極悪非道な考えが頭をよぎったりしませんか?
そう、それが「チート部門①」です。
console.assert() がエラーを投げないように書き換える
console.assert()
は実は上書き可能です。console.assert()
に限らず、大抵のものは上書きできちゃいます。JavaScript 万歳。
ということで、何もしない関数で上書きしましょう。
例えばこう。
console.assert = () => {};
おっと、エラーが出ちゃいました。
そうでした compare.js からは関数を export
することが求められているのです。
何らかの関数を export
しましょう。今回は何もしない関数を export
すれば OK です。
console.assert()
を上書きするので、compare.js から export
する関数、つまり2つの画像の横長具合を比較する関数は真面目に仕事をする必要はありません!
なのでこうしちゃいます。
console.assert = () => {}; export default () => {};
動いた! 後ろめたさが多少ありますが51文字を達成しました。
ここからどんどん短くしていきましょう。
() => {}
が2回登場します。これをまとめましょう。
export default console.assert = () => {};
ホワイトスペースや文末のセミコロンなど、省略できるものは省略します。
export default console.assert=()=>{}
36文字!
()
だと文字数を2文字も消費してしまうので、1文字にするために無駄に引数を受け取ることにします。
(私は宣言せざるを得ないが使う予定のない引数は「使わないぞ!」感をほのめかすためによく _
にします。例えば replace()
に渡す置換関数で、第1引数は使わないけど第2引数以降は使いたい時など。TypeScript であればさらに _
の型を unknown
にすれば、本当に使えなくなって良いかもしれません)
export default console.assert=_=>{}
35文字!
console.assert()
の戻り値は元々無い、つまり戻り値は undefined
です。
が、main.js では console.assert()
の戻り値は使っていないので undefined
でなくても、何を返しても良いですね!
例えば1文字で書ける 0
とか。
export default console.assert=_=>0
34文字!
もちろん1文字であれば何でも良いですね! 1
や 2
、3
でも。
export default console.assert=_=>1
export default console.assert=_=>2
export default console.assert=_=>3
34文字!
無駄に _
で受け取った引数を、そのまま左から右に横流ししても良さそうです。
このほうがなんかスマートな感じがします。sugyan さんの回答がこちら!
export default console.assert=_=>_
34文字!
グローバル関数を利用する
実は私はここまでしか想定していなかったのですが、もっと別な方法がありました。 自作の適当な関数ではなく、既存の関数、グローバル関数(で要件を満たす関数)を返す方法です。
gfx さんの回答がこちら!
export default console.assert=Date
同じく34文字!
とってもスマートな気がします!
Date
はコンストラクタ関数ですが new
演算子無しでも使えますね。そして画像を2つ渡しても怒らないという寛容さ。やさしい。
ということは、、、もし3文字、2文字、1文字のグローバル関数があったら34文字未満も可能になってしまう!まずい、、「チート部門①」は34文字と言ってしまった!「ハック部門」の33文字のほうが1文字少ない設計が破綻してしまいます。
恐る恐る検証しましょう。
画像を2つ渡しても怒らないかどうかは置いておき、まずは Date
の4文字よりも文字数が少ない、つまり 3文字、2文字、1文字のグローバル関数(コンストラクタ関数も含む)を探してみます。
例えばこんなコードで。
Object.keys(window).filter(key => key.length < 4).filter(key => typeof window[key] === 'function');
あれ!1個も無い!
そんなはずはありません。
4文字未満の(コンストラクタ)関数は Map
や Set
など、ちょっと考えただけでもいくつか思いつきます。
あ!Map
や Set
は列挙可能(enumerable)ではないのですね。うっかりしてました。
window.propertyIsEnumerable('Map') // => false
window.propertyIsEnumerable('Set') // => false
列挙可能(enumetrable)でないプロパティも列挙したいので、Object.keys(window)
の代わりに Object.getOwnPropertyNames(window)
を使いましょう。
Object.getOwnPropertyNames(window).filter(key => key.length < 4).filter(key => typeof window[key] === 'function');
3文字以下の(コンストラクタ)関数がたくさん見つかりました!初めてお目にかかるものもいくつか。
'Map', 'Set', 'URL', 'Ink', 'GPU', 'HID', 'USB', 'dir', '$', '$$', '$x'
画像を2つ渡しても怒らない寛容な(コンストラクタ)関数のみにフィルターしてみます。
Object.getOwnPropertyNames(window) .filter(key => key.length < 4) .filter(key => typeof window[key] === 'function') .filter(key => { const f = window[key]; let throwsError = false; try { f(new Image, new Image); } catch { throwsError = true; } return !throwsError; });
catch
ブロックの ()
はいつの間にか省略できるようになっていました。ECMAScript 2019 (ES10) から。
'dir', '$', '$$', '$x'
3文字以下で画像を2つ渡しても怒らない寛容な(コンストラクタ)関数があった?! やはり「チート部門①」で 34文字未満が可能だったのでしょうか。
いえ、たぶん不可能なはず。なぜなら、dir()
、$()
、$$()
、$x()
は Chrome の Console Utilities API、つまり Chrome で且つコンソール上でのみ使える API だからです。
念の為一つ一つおさらいしましょう。
window.dir()
window.dir(object)
dir
は console.dir()
のエイリアス、ショートカットらしいです。知らなかった!
window.$、window.$$
window.$(selector [, startNode])
window.$$(selector [, startNode])
これは皆さんもよく使うかもしれない document.querySelector()
と document.querySelectorAll()
のエイリアス、ショートカットですね。エイリアス名にドルマーク $
を使用しているのは、懐かしの prototype.js、jQuery.js などの $()
$$()
にあやかっているのでしょうか。 document.querySelector()
や document.querySelectorAll()
がまだ無かったあの頃を思い出します。辛かった IE6、IE7、IE8、、、との戦い。サヨナラ IE。サヨナラ後方互換モード。
window.$x()
window.$x(path [, startNode])
これまた懐かしの? XPath を引数に DOM を取得する関数です。
ということは document.evaluate()
のエイリアスでしょうか? 引数が若干違いますが。
XPath、(昔もそんなに使わなかったですが)最近は全く使わないので存在をすっかり忘れていました。ついでに E4X(ECMAScript for XML)なんてのもあったなとふと思い出したり。
私の周りでは、昔は(サーバ側の)API が JSON ではなく XML を返す事が多かったので、サーバが返してきた XML から必要なデータを取り出す際に使っていたような気もしますが、今は JSON が多いので使う機会がほぼなくなっちゃいました。JSON 万歳。
でも改めて今振り返ると、意外と(DOM 操作に)便利かもしれない?気が向いたら使ってみます。
ちょっと話がずれましたが、
'dir', '$', '$$', '$x'
はいずれも Chrome の Console Utilities API なので、ブラウザの開発者コンソール以外では使えませんね!
console
オブジェクトにはこんな感じで便利な機能がたくさん備わっていますが、console
がない時代はいちいち alert()
で表示したり、DOM で画面の左上にログを表示したりなど、今振り返ると大変でした。
export default console.assert=dir
export default console.assert=$
export default console.assert=$$
export default console.assert=$x
いずれも無事?エラーとなりました。
念の為以下の(すでに filter()
で弾かれている)候補も試してみましょうか。
'Map', 'Set', 'URL', 'Ink', 'GPU', 'HID', 'USB'
window.Map
まずは皆さんご存知の Map
から。
export default console.assert=Map
Map
は new
無しで普通の関数のように使うことはできないみたいです。
window.Set
export default console.assert=Set
Set
も new
無しで普通の関数のように使うことはできないみたいです。
window.URL
export default console.assert=URL
URL
も new
無しで普通の関数のように使うことはできないみたいです。
(url 文字列を動的に作る際は、昔のように文字列の連結など自力で作るのは避け、 このビルトインの new URL()
を使いましょう。XSS にも要注意)
window.Ink
export default console.assert=Ink
Ink
クラスなんでのがあるんですね。初めて知りました。まだ Experimental のようです。
エラーのメッセージからは直接読み取れませんがInk
は(CSSStyleDeclaration
等と同様)ユーザが自分で new
することはできないようです。
document.body.style.constructor; // => CSSStyleDeclaration() { [native code] }
const style = new CSSStyleDeclaration(); // => TypeError: Illegal constructor
window.GPU
export default console.assert=GPU
GPU
クラスも初めて知りました。こちらも Experimental。
これまたエラーのメッセージからは直接読み取れませんが GPU
もユーザが自分で new
することはできないようです。
window.HID
export default console.assert=HID
HID
クラスも初めて知りました。HID は Human Interface Device の略らしいです。一瞬バイクや車の HID(High Intensity Discharge lamp)かと思いました。知らないクラスがどんどん登場しますね。HID クラスもまだ Experimental です。
HID
もユーザが自分で new
することはできないようです。
window.USB
export default console.assert=USB
いよいよ最後の USB
クラス。こちらも Experimental。
USB
もユーザが自分で new
することはできないようです。
というわけで、「チート部門① console.assert() を上書きする」の最短文字数は、当初の想定通り34文字のはずです!でももっと短くできる、という人はぜひ教えてください。
チート部門② 文字列を取得・整形する処理を上書きする
コードの文字列を取得・整形する処理を見てみましょう。具体的には main.js の以下の部分です。
const { length } = await fetch('compare.js') .then((res) => res.text()) .then((str) => str.trim());
文字列を取得するのに、関数・メソッドが複数使われています。いずれかを上書きすることで文字数をごまかせそうですね!
trim() を上書きする
trim()
は String
クラスの「インスタンスメソッド」です。
以降「クラスメソッド」(static
なメソッド、静的メソッド)と比較する意味で「インスタンスメソッド」という呼称とします。
trim()
のように、あるクラスの「インスタンスメソッド」(の大元)を上書きするには「プロトタイプベースのオブジェクト指向言語」である JavaScript の根幹、「プロトタイプオブジェクト」の概念を知る必要があります。
※ 全てのインスタンスではなく、特定のインスタンスのみの「インスタンスメソッド」を上書きする場合は、直接上書きすれば OK です
最近では、クラスベースの他の言語のようにクラスを定義できるクラス記法が導入され「プロトタイプオブジェクト」の存在を直接意識する機会は減りました。
(今回の trim()
の挙動を説明するには、さらに「ラッパーオブジェクト」の概念も関わってきますが、本記事では割愛します)
プロトタイプオブジェクト
「プロトタイプオブジェクト」をざっくり、本当にざっくり説明します。 以下のオブジェクトを考えます。
const obj = { hoge: 123, };
obj
の hoge
にアクセスした際の挙動
obj.hoge // => 123
と、
obj
の(存在しない)piyo
プロパティにアクセスした際の挙動
obj.piyo // => undefined
を、比較してみます。
obj.hoge
はシンプルですね。obj
には hoge
というプロパティがあるので、その値が素直に参照されます。
obj.hoge // => 123
obj.piyo
はどうでしょう?
obj
には piyo
というプロパティは見当たりません。では JavaScript はもうその時点で piyo
は undefined
であると確定するでしょうか?実は、そうではありません!
obj.piyo // => undefined
obj
に piyo
が無かったとしても、JavaScript まだ諦めません。obj
に紐付いている「プロトタイプオブジェクト」に piyo
が無いかを JavaScript は探しに行きます。
obj
に紐付いている「プロトタイプオブジェクト」は、この場合は Object.prototype
です。JavaScript は Object.prototype
オブジェクトに piyo
プロパティが無いかを探しに行ってくれるのです。
で、 Object.prototype
オブジェクトにも piyo
プロパティには当然無いですね!でもまだまだ JavaScript は諦めません。
今度はさらに Object.prototype
オブジェクトに紐づいている「プロトタイプオブジェクト」をチェックしようとします。結果的には Object.prototype
に紐づいている「プロトタイプオブジェクト」は無いので Object.prototype
がこのループというか一連の流れ、連鎖、チェーンのゴールです。JavaScript の piyo
を探す長い?旅がようやく終了します。そこで初めて piyo
が undefined
であることが確定します。
この、JavaScript が「プロトタイプオブジェクト」をどんどん遡って探していく仕組みを「プロトタイプチェーン」と呼びます。
試しに Object.prototype
に piyo
プロパティを定義し、obj.piyo
の評価結果がどうなるかを見てみましょう。
Object.prototype.piyo = 999; const obj = { hoge: 123, }; obj.piyo; // => 999
obj
自体には piyo
プロパティは存在しないのに、obj.piyo
は 999
になりました!
これが「プロトタイプチェーン」の働きです。
new
演算子
「プロトタイプオブジェクト」、「プロトタイプチェーン」をざっくり説明しました。プロパティを例に説明しましたがメソッドの場合も同様です。
「プロトタイプチェーン」の仕組みを使うと、あるクラスのインスタンスのメンバ、(共用の)プロパティやメソッドを、インスタンスに直接生やすのではなく、インスタンスに共通で紐づく「プロトタイプオブジェクト」に定義することで、メモリを節約できます。JavaScript のビルトインのクラス、Object
、String
、Number
、Array
などの「インスタンスメソッド」も同じ仕組みです。
ちなみに、クラスのインスタンスに「何らかのオブジェクト」を「プロトタイプオブジェクト」として紐づける役割を担っているのが new
演算子です。new
演算子がインスタンスに紐づけてくれる「何らかのオブジェクト」は、コンストラクタ関数に生えている prototype
という名前のオブジェクト、と決まっています。大抵の関数にはデフォルトで prototype
という名前のプロパティ、オブジェクトが定義されています。prototype
という名前のプロパティを持たない関数は、例えば「アロー関数」や bind()
で this
が束縛されている関数です。どちらも this
の挙動が通常の function
で定義される関数式/文と違いますね!
通常の関数(式)
const f = function() {}; console.log(f.prototype); // => {} console.log(f.hasOwnProperty('prototype')); // => true const instance = new f;
アロー関数
const f = () => {}; console.log(f.prototype); // => undefined console.log(f.hasOwnProperty('prototype')); // => false const instance = new f; // => TypeError: f is not a constructor
bind() で this が拘束された関数
const f = function() {}; const g = f.bind({}); console.log(g.prototype); // => undefined console.log(g.hasOwnProperty('prototype')); // => false const instance = new g; // => コンストラクタ関数としては一応使える
「(新規の)オブジェクト」に「何らかの既存オブジェクト」を「プロトタイプオブジェクト」として結びつけることは、昔は new
演算子 (or 非公式には __proto__
プロパティの書き換え)でしかできませんでしたが、最近では Object.create()
や Object.setPrototypeOf()
でもできるようになりました。
String.prototype.trim()
の上書き
さて、前置きが長くなりました。
ここでようやく trim()
を上書きする話に戻します。trim()
は String
クラスの「インスタンスメソッド」なので、trim()
の実態は String.prototype.trim()
です。
'123'
や 'abc'
などの文字列リテラルは厳密には String
クラスのインスタンスではないですが、「ラッパーオブジェクト」の概念の解説が必要なため本記事では割愛します
trim()
は文字列の前後の空白を取り除いてくれるメソッドです。
昔は正規表現で取り除いていましたが、trim()
が登場してからはよりシンプルに書けるようになりました。trimStart()
や trimEnd()
もありますね!
' 12 3 '.replace(/^\s+/, '').replace(/\s+$/, ''); // => '12 3'
' 12 3 '.trim(); // => '12 3'
さて、第2問の文字数を所得する部分のコードを振り返りましょう。
const { length } = await fetch('compare.js') .then((res) => res.text()) .then((str) => str.trim()); console.log( `%cJS体操 No. 2\n%c${length}%c文字\n%cpowered by 面白法人カヤック`, 'font-weight:bold;font-size:200%;', 'font-weight:bold;font-size:800%;color:#0af;', 'font-size:100%;', 'font-weight:bold;font-size:100%;color:#fc6;', );
trim()
が空文字列を返せば文字数を 0
にできちゃいますね!
早速やってみましょう。
trim() が空文字を返すようにする
String.prototype.trim = () => ''; export default(a,b,f=e=>e.naturalWidth/e.naturalHeight)=>f(a)-f(b) // 66文字の正攻法回答(最後のセミコロンは省略)
0文字達成!
多くの方がこのアプローチをしていました。tkihira さんはすでに第1問の段階で0文字を達成していました笑
length をオーバーフローさせる?
試しに、文字列の length
をオーバーフローさせちゃえば?!と思いやってみましたが、オーバーフローする前にしっかりとエラーが出てダメでした。
repeat()
でとーっても長い文字列を作成しようとしたのですが、手元の環境では 2**29 - 24
より大きいとエラーになりました。
const striiiiiiiiiiing = 'a'.repeat(2**29 - 24); // とーっても長い文字列 String.prototype.trim = () => striiiiiiiiiiing; export default(a,b,f=e=>e.naturalWidth/e.naturalHeight)=>f(a)-f(b) // 66文字の正攻法回答(最後のセミコロンは省略)
const striiiiiiiiiiing = 'a'.repeat(2**29 - 23); // とーっても長い文字列 String.prototype.trim = () => striiiiiiiiiiing; export default(a,b,f=e=>e.naturalWidth/e.naturalHeight)=>f(a)-f(b) // 66文字の正攻法回答(最後のセミコロンは省略)
trim() が String-like なオブジェクトを返す
さてここで、私が想定していなかった回答をご紹介します。ほーくさんのアプローチです。
trim()
の戻り値は「文字列(string)」ですが JavaScript なので(要件を満たせば)何を返しても良いですね!
今回の場合は length
というプロパティを持っていれば何でも良いです。
String.prototype.trim = () => ({ length: -999, // 何でも良い Number.MIN_SAFE_INTEGER や Number.NEGATIVE_INFINITY でも。 }); export default(a,b,f=e=>e.naturalWidth/e.naturalHeight)=>f(a)-f(b) // 66文字の正攻法回答(最後のセミコロンは省略)
想定していないアプローチの回答があると悔しいとともにとても嬉しいです!ほーくさんありがとうございます!!!
Response.prototype.text()
を上書きする
再び文字数を所得する部分のコードを振り返りましょう。
const { length } = await fetch('compare.js') .then((res) => res.text()) .then((str) => str.trim()); console.log( `%cJS体操 No. 2\n%c${length}%c文字\n%cpowered by 面白法人カヤック`, 'font-weight:bold;font-size:200%;', 'font-weight:bold;font-size:800%;color:#0af;', 'font-size:100%;', 'font-weight:bold;font-size:100%;color:#fc6;', );
trim()
のひとつ前の段階、(res) => res.text()
の text()
を上書きしても良いですね!
Response
クラスの「インスタンスメソッド」text()
です。実態は Response.prototype.text()
。
Response.prototype.text = () => ''; export default(a,b,f=e=>e.naturalWidth/e.naturalHeight)=>f(a)-f(b) // 66文字の正攻法回答(最後のセミコロンは省略)
プロトタイプ汚染
String.prototype.trim()
や Response.prototype.text()
など、ビルトインのクラスのプロトタイプオブジェクトに手を加えることは「プロトタイプ汚染」とも呼ばれ禁じ手です。実務では控えましょう。自分が書いたコード、チームメンバーが書いたコードのみならず、サードパーティ製のライブラリやフレームワークなどにも多大な影響を及ぼします!
fetch()
を上書きする
更に手前の fetch()
も上書きしてみましょう。
fetch()
の戻り値の型は Promise<Response>
ですが、今回は Promise<Response-like な何か>
でも良いです。
fetch = async () => ({ text: () => '', }); export default(a,b,f=e=>e.naturalWidth/e.naturalHeight)=>f(a)-f(b) // 66文字の正攻法回答(最後のセミコロンは省略)
グローバル汚染
「グローバル汚染」という言葉もあります。
fetch()
などのビルトインのグローバル関数を意図的に上書きすることも「グローバル汚染」と言うのでしょうか?「グローバル汚染」というと、(主に意図せず、時には意図して)グローバル変数を作って(結果既存のグローバル変数を上書きして)しまうことを指すことが多いような気がしますが。とにかく fetch()
などのグローバル関数を上書きするなんてもってのほか、厳禁です。実務では控えましょう。
「チート部門②」は以上です!
チート部門③ 文字数を表示する処理を上書きする
最後は、文字数を表示する処理、つまりconsole.log()
を上書きしちゃいましょう。
解説は割愛しサンプルコードのみで。
const log = console.log; // オリジナルの console.log をバックアップ const length = -999; // 解説用に一時変数に console.log = () => log( `%cJS体操 No. 2\n%c${length}%c文字\n%cpowered by 面白法人カヤック`, 'font-weight:bold;font-size:200%;', 'font-weight:bold;font-size:800%;color:#0af;', 'font-size:100%;', 'font-weight:bold;font-size:100%;color:#fc6;', ); export default(a,b,f=e=>e.naturalWidth/e.naturalHeight)=>f(a)-f(b)// 66文字の正攻法回答
より丁寧に、正規表現を使って「〇〇文字」の「〇〇」の部分のみを差し替えるという回答もありました!
まとめ
長い記事になりましたが、最後まで読んでいただきありがとうございます。
「チート部門」の解説、いかがでしたでしょうか。 いずれもしょうもないアプローチではありますが、「プロトタイプオブジェクト」など JavaScript の知識が求められるテクニックでした。
『JS体操』には「失格」という概念はないので今後の問題でもあらゆるアプローチをお待ちしておりますが、今回ご紹介したアプローチは「既出」ということで、解説ブログでは封印します笑
本記事でご紹介した汎用的なチートをプログラム的に封印するために、いわゆるサンドボックスを作成するというテーマも技術的に面白そうですね。機会があればブログを書いてみようと思います。
他にも汎用的なチートがあるかもしれないですし、問題によっては、その問題ならではのチート法、出題側が想定していないチートがあるかもしれません。 もしそんなチートを見つけた方は、遠慮なくどしどしご応募ください。楽しみにしています!
『JS体操』第2問の解説は以上になります。
第3問も近日中に公開予定です。みなさまの回答をチーム一同楽しみにしています。
ではまた!
お知らせ
次回以降の「解説ブログ」や「JS体操の問題」のお知らせが気になる方は以下よりご登録ください。
公開次第、ご連絡します!
『JS体操』第1問&第2問、まだ挑戦していない!という方はぜひ
先日、Perl のコードゴルフコンテストもカヤック主催で開催されました。
こちらも面白いのでぜひご覧ください!
そして、カヤックではコードゴルフが大好きな新卒&中途エンジニアも募集しています!