Panda Noir

JavaScript の限界を究めるブログでした。最近はいろんな分野を幅広めに書いてます。

LINE のコーディングテストを Rust で解いてみた

Rust の練習としてLINEのコーディングをやってみました。以前からずっとやりたかったのですが、問題も長くて面倒でやってなかったんですよね(実際めんどうだった)…

今回解いた問題

以下解説していきます

(注: 入出力の例が一切なく、ジャッジシステムも見当たらなかったので、コードが問題の意図通り動いているか保証されていません。仕様の誤読やコーディングミスがある恐れがあります)

問題概要

問題本文はこちら

タクシーの走行記録が入力として渡されるので料金を求めよ。

  • 走行距離に応じた料金と、低速走行時間に応じた料金の合計が料金
    • 走行距離は初乗り410円、1052m以降は237mごとに80円
    • ただし、深夜時間帯は走った距離が1.25倍されて計算される
    • 低速走行とは、10km/h以下で走ることを指す。降車までの総低速時間に対して90秒ごとに80円かかる。

ざっと要約するとこんな感じです。

方針

仕様があまりに複雑なので、テスト駆動っぽく解きました。

  1. 走行距離のみ考慮して計算する(深夜時間帯、低速走行を含まないケースのテストを書く)
  2. 深夜時間帯を考慮して計算する
  3. 低速走行を考慮して計算する

だいたいこんな感じで段階を踏みながら関数をリファクタリングしていきました。

実際のコード

テストコードを抜いておよそ80行になりました。

use std::io::{self, BufRead, BufReader};

type Record = Vec<(f64, f64)>;
fn to_sec(h: u32, m: u32, s: f64) -> f64 {
    ((h * 60 + m) * 60) as f64 + s
}
fn is_midnight(s: f64) -> bool {
    // 22時になってから5時になるまで(5時を含まない)が深夜時間帯
    match s {
        t if t < to_sec(5, 0, 0.0) => true,
        t if t >= to_sec(22, 0, 0.0) => true,
        _ => false,
    }
}
// 走行距離を求める関数。深夜時間帯は実際に走った距離の1.25倍走行したと見なす
fn measure_distance(v: &Record) -> f64 {
    let mut distance = v.into_iter().fold(0.0, |sum, (_, meter)| sum + meter);
    for x in v.windows(2) {
        let (t1, ..) = x[0];
        let (t2, meter2) = x[1];
        if is_midnight(t1) && is_midnight(t2) {
            distance = distance + 0.25 * meter2;
        }
    }
    distance
}
// 低速(10km/h以下)で走った総時間を求める関数。深夜時間帯は実際の低速走行時間の1.25倍走行したと見なす。
fn sum_slow_running_time(v: &Record) -> f64 {
    let mut slow_running_time = 0.0;
    for x in v.windows(2) {
        let (t1, ..) = x[0];
        let (t2, meter2) = x[1];
        let dt = t2 - t1;
        if (meter2 / dt) * 60.0 * 60.0 > 10000.0 {
            continue;
        }

        slow_running_time += if is_midnight(t1) && is_midnight(t2) {
            dt * 1.25
        } else {
            dt
        }
    }
    slow_running_time
}
// 走行距離に応じた料金
fn calc_distance_based_fare(v: &Record) -> u32 {
    410 + ((measure_distance(v) - 1052.0) / 237.0).ceil() as u32 * 80
}
// 低速で走った時間に対する料金
fn calc_slow_fare(v: &Record) -> u32 {
    (sum_slow_running_time(v) / 90.0).floor() as u32 * 80
}
fn calc_fare(v: &Record) -> u32 {
    calc_distance_based_fare(v) + calc_slow_fare(v)
}

fn read_from_stdin() -> Record {
    let mut time_records: Record = vec![];
    let mut reader = BufReader::new(io::stdin());
    let mut s = String::new();

    while reader.read_line(&mut s).expect("Failed to read line.") > 0 {
        let v: Vec<_> = s.split_whitespace().collect();

        let meter = v[1].parse().unwrap_or(0.0);
        let time: Vec<_> = v[0].split(':').collect();

        let hour = time[0].parse().unwrap_or(0);
        let min = time[1].parse().unwrap_or(0);
        let sec = time[2].parse().unwrap_or(0.0);
        time_records.push((to_sec(hour, min, sec), meter));
        s.clear();
    }
    time_records
}
fn main() {
    let time_records = read_from_stdin();
    println!("{}", calc_fare(&time_records));
}

テストコードはこんな感じです。

#[test]
fn distance_based_fare() {
    // 通常時間帯のみ、低速賃金や深夜割増が発生しないケース
    let record = vec![(to_sec(7, 0, 0.0), 0.0), (to_sec(7, 1, 0.0), 1052.0)];
    assert_eq!(measure_distance(&record), 1052.0);
    assert_eq!(calc_distance_based_fare(&record), 410); // 1052m/min = 63.12km/h

    let record = vec![(to_sec(7, 0, 0.0), 0.0), (to_sec(7, 1, 0.0), 1053.0)];
    assert_eq!(measure_distance(&record), 1053.0);
    assert_eq!(calc_distance_based_fare(&record), 490);

    let record = vec![(to_sec(7, 0, 0.0), 0.0), (to_sec(7, 1, 0.0), 1289.0)];
    assert_eq!(measure_distance(&record), 1289.0);
    assert_eq!(calc_distance_based_fare(&record), 490);

    let record = vec![(to_sec(7, 0, 0.0), 0.0), (to_sec(7, 1, 0.0), 1290.0)];
    assert_eq!(measure_distance(&record), 1290.0);
    assert_eq!(calc_distance_based_fare(&record), 570);
}
#[test]
fn midnight_fare() {
    // 深夜割増のケース
    let record = vec![(to_sec(0, 0, 0.0), 0.0), (to_sec(0, 1, 0.0), 841.0)];
    assert_eq!(measure_distance(&record), 1051.25);
    assert_eq!(calc_distance_based_fare(&record), 410);

    let record = vec![(to_sec(0, 0, 0.0), 0.0), (to_sec(0, 1, 0.0), 842.0)];
    assert_eq!(measure_distance(&record), 1052.5);
    assert_eq!(calc_distance_based_fare(&record), 490);

    let record = vec![(to_sec(0, 0, 0.0), 0.0), (to_sec(0, 1, 0.0), 1031.0)];
    assert_eq!(measure_distance(&record), 1288.75);
    assert_eq!(calc_distance_based_fare(&record), 490);

    let record = vec![(to_sec(0, 0, 0.0), 0.0), (to_sec(0, 1, 0.0), 1031.3)];
    assert_eq!(measure_distance(&record), 1289.125);
    assert_eq!(calc_distance_based_fare(&record), 570);
}
#[test]
fn midnight_fare2() {
    // 通常時間帯 → 深夜時間帯のケース
    let record = vec![
        (to_sec(21, 59, 0.0), 0.0),
        (to_sec(22, 0, 0.0), 927.0),
        (to_sec(22, 0, 10.0), 100.0),
    ];
    assert_eq!(measure_distance(&record), 1052.0);
    assert_eq!(calc_distance_based_fare(&record), 410);

    let record = vec![
        (to_sec(21, 59, 0.0), 0.0),
        (to_sec(22, 0, 0.0), 927.1),
        (to_sec(22, 0, 10.0), 100.0),
    ];
    assert_eq!(measure_distance(&record), 1052.1);
    assert_eq!(calc_distance_based_fare(&record), 490);

    let record = vec![
        (to_sec(21, 59, 0.0), 0.0),
        (to_sec(22, 0, 0.0), 927.0),
        (to_sec(22, 0, 10.0), 100.1),
    ];
    assert_eq!(measure_distance(&record), 1052.125);
    assert_eq!(calc_distance_based_fare(&record), 490);
}
#[test]
fn slow_fare() {
    let record = vec![(to_sec(7, 0, 0.0), 0.0), (to_sec(8, 0, 0.0), 10000.1)];
    assert_eq!(sum_slow_running_time(&record), 0.0);

    let record = vec![(to_sec(7, 0, 0.0), 0.0), (to_sec(8, 0, 0.0), 10000.0)];
    assert_eq!(sum_slow_running_time(&record), 3600.0);

    let record = vec![(to_sec(0, 0, 0.0), 0.0), (to_sec(1, 0, 0.0), 10000.0)];
    assert_eq!(sum_slow_running_time(&record), 3600.0 * 1.25);
}

document.readyState と load, DOMContentLoaded のタイミングについて

結論

  • complete になると同時に load が発火する(仕様)
  • interactive になったあとかつ complete になる前に DOMContentLoaded が発火する(仕様)

厳密には complete になったあとに load が発火するという仕様です。この前後関係があるため、 readystatechange -> load イベントの順が保証されています。

load 時に何か処理を行いたいとき

const addLoadEventListener = (f) => {
  if (document.readyState === 'complete') {
    f();
  } else {
    window.addEventListener('load', f);
  }
};

こうすると、すでに load イベントが実行済みだった場合も処理が実行されます。

readyState の変わるタイミングを検証するコード

こんな感じで検証しました。

const id = setInterval(() => {
  console.log(document.readyState);
}, 1000 / 100);

console.log('immediately',document.readyState);

window.addEventListener('DOMContentLoaded', () => {
  console.log('DOMContentLoaded', document.readyState);
});

window.addEventListener('load', () => {
  console.log('load', document.readyState);
  clearInterval(id);
});
window.addEventListener('readystatechange', () => {
  console.log('readystatechange', document.readyState);
});

DOMContentLoaded と load を数秒離すために画像を埋めて計測しました。document.readyState が complete になるのと load イベントが発火するのは必ず同時でした。

React 18からマウント時にuseEffectが2回呼ばれる訳じゃないよ

結論: 「<StrictMode/> で囲まれてて」「開発ビルドのときだけ」マウント時に useEffect が2回発火するようになります。

あくまで検証が目的の変更

一見、マウント時にcleanupが走るのは無駄に思えますよね?その通りです。2回呼ばれる合理的な理由はありません。

ではなぜ useEffect が2回発火するのかというと、検証のためです。

実は React はマウント/アンマウント時以外にもuseEffectを呼び出すような API の導入を予定しています。つまり、以下のコードはマウント時以外にも呼ばれるようになります。

useEffect(() => {
  // mount 時以外も呼ばれる可能性がある!
}, []);

(とはいえまだ当分の間はマウント時のみという認識でいいと思います。Offscreen APIなどが来るまでは大丈夫です)

こうなる予定があるので、「マウントからアンマウントまでに2回 useEffect が走っても問題ないか」の確認を促す意味で StrictMode で useEffect が2回実行されるようになった、ということらしいです。

実際、検証が目的なので production build では useEffect は2回実行されません。 ご安心ください。

React の lazy をちょっといい感じに書きたい

名前付きエクスポートされているコンポーネントを React で 動的インポートをするには、以下のように書く必要があります。

const MyComponent = lazy(() =>
  import('./Component').then(({ MyComponent }) => ({ default: MyComponent }))
);

ただ、ちょっとかったるいというか、何やっているか分かりづらいですよね。そこで、pick というヘルパー関数を作ってみました。こんな感じで使います。

const pick = <T extends unknown>(name: keyof T) => (items: T) => ({
  default: items[name],
});
const MyComponent = lazy(() => import('./Component').then(pick('MyComponent')));

まだ行けてないなとは自分でもちょっと思いますが、さっきよりはマシになったと思います。関数にしてうまくやりたかったんですが、dynamic import 周りの制約がいろいろあるせいで結構難しかったです…

React で touchstart で preventDefault したいとき

touch イベントと mouse イベントの両方に対応したいとき、touchstart 内で preventDefault を呼び出すというテクニックがあります。こうすると、touchstart、touchend のみが発火してそのあとのmousedown、mouseup、click が発火しなくなり、touch イベントと mouse イベントをそれぞれ独立して設定ができます。

しかし、React では事情があって onTouchStart のなかで preventDefault を呼び出すことができません (後述)。そのため、特殊なやり方をする必要があります。

結論

以下の usePreventDefault を使うことで、touchstart でも preventDefault を呼び出すことができます。

import { useEffect, useRef } from 'react';

export const usePreventDefault = <T extends HTMLElement>(
  eventName: string,
  enable = true
) => {
  const ref = useRef<T>(null);
  useEffect(() => {
    const current = ref.current;
    if (!current) {
      return;
    }
    const handler = (event: Event) => {
      if (enable) {
        event.preventDefault();
      }
    };
    current.addEventListener(eventName, handler);
    return () => {
      current.removeEventListener(eventName, handler);
    };
  }, [enable, eventName]);

  return ref;
};

このように使います。

const App = () => {
  const ref = usePreventDefault<HTMLDivElement>('touchstart');
  return (
    <div
      ref={ref}
      onTouchStart={() => {}}
      onTouchEnd={() => {}}
      onMouseDown={() => {}}
      onMouseUp={() => {}}
      onClick={() => {}}
    >click me</div>
  );
};

usePreventDefault の内部で touchstart 時に preventDefault を呼び出すように設定したので、あとは好きなようにできます。タッチしたあとに mousedown が発火することもありません。

そもそもなぜ React では touchstart 内で preventDefault ができないのか

愚直に書くとしたら、次のようになるはずです。しかし、これでは動きません。

<div onTouchStart={event => event.preventDefault()}>

demo

「Unable to preventDefault inside passive event listener invocation.」というエラーが出ます。passive なイベントリスナーというのは、かんたんに言えば「preventDefaultは呼び出さないよ」と宣言したイベントリスナーです。呼び出さないと宣言しているのに preventDefault を呼び出しているので怒られているというわけです。

では、なぜ React は touchstart イベントハンドラーを passive で登録しているのでしょうか?実はこれはブラウザの仕様と関係しています。

React ではイベントリスナーを各要素につけるのではなく、documentレベルにイベントリスナーをまとめてアタッチしています*1。Chrome などには document レベルに貼られた touchstart イベントは自動的に passive なイベントリスナーとして扱われるという制約があるため(参照)、React が document ルートにはりつけた onTouchStart は passive として登録されます*2。そのため preventDefault を呼び出せないのです。

解決方法

解決方法はかんたんで、passive でない形で自前でイベントリスナーを貼るだけです。

const App = () => {
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => {
    const current = ref.current;
    if (!current) return;
    const onTouchStart = (event: Event) => event.preventDefault();
    current.addEventListener('touchstart', onTouchStart);
    return () => {
      current.removeEventListener('touchstart', onTouchStart);
    };
  }, []);
  return <div ref={ref}>click me</div>;
};

これで touchstart のデフォルト動作を止めることができたので、あとは React の onTouchStart を自由に使えます。

カスタムフックに抜き出す

上のままでもいいですが、カスタムフックに抜き出すとよりわかりやすくなります。

import { useEffect, useRef } from 'react';

export const usePreventDefault = <T extends HTMLElement>(
  eventName: string,
  enable = true
) => {
  const ref = useRef<T>(null);
  useEffect(() => {
    const current = ref.current;
    if (!current) {
      return;
    }
    const handler = (event: Event) => {
      if (enable) {
        event.preventDefault();
      }
    };
    current.addEventListener(eventName, handler);
    return () => {
      current.removeEventListener(eventName, handler);
    };
  }, [enable, eventName]);

  return ref;
};

*1:v17からは React ツリーをレンダーしようとしているルート DOM コンテナにアタッチします

*2:v17からはドキュメントルートではなくなりましたが、breaking changes を最小限にとどめるために、明示的に passive を指定してあります