『リーダブルコード―より良いコードを書くためのシンプルで実践的なテクニック』を実践的に活用するための問題集第2弾です。
第一弾の記事はこちら。
使用している言語はTypeScriptで、範囲は第Ⅱ部「ループとロジックの単純化」と第Ⅲ部「コードの再構成」です。
問題を4つ掲載した後に解答例を記載しています。ぜひ腕試しとして解いてもらったり、改善や感想のフィードバックをもらえたりすると嬉しいです!
問題1 既存の分岐に新しい条件を追加する
ユーザーが2020年から利用しているユーザーかどうかでサイトのヒーローイメージを変更するためのTypescriptの関数がある。
interface User {
	id: number;
	cYear: number;
	hasJoinedCampaign: boolean;
}
const changeHeroImage = (user: User) => {
	return user.cYear === 2020 ? '/public/img/campaign' : '/public/img/normal';
}
加えて、2020年からのユーザーが3周年キャンペーンに参加していたら’/img/specialParticipants’を表示する条件を追加せよ(本サービス開始年は2020年とする)
問題2 不要な変数の削除
配列から値を削除するTypescriptの関数がある。
- この関数の機能が分かりやすくなるようコメントを追記しろ
- この関数の中から不要な変数を削除して読みやすく書き直せ
const removeValFromArr = <T>(arr: T[], valToRemove:T) => {
  let indexToRemove: number = undefined;
  for (let i = 0; i < arr.length; i += 1) {
    if (arr[i] === valToRemove) {
      indexToRemove = i;
      break;
    }
  }
  if (index_to_remove !== undefined) {
    arr.splice(indexToRemove, 1);
  }
};
問題3 関数の分割
以下のようなReactコンポーネントがある。JSONPlaceholderのサンプルAPIエンドポイントとGet通信して、その結果返ってきたオブジェクトをAlert()でポップアップ表示している。
import axios from "axios";
export function Question() {
  const handleClick = () => {
    axios
      .get("https://jsonplaceholder.typicode.com/users")
      .then((res) => {
        let str = "{\n";
        for (const key in res.data) {
          str += " " + key + " : " + res.data[key] + "\n";
        }
        alert(str + "}");
      })
      .catch((err) => console.log(err));
  };
  return <button onClick={() => handleClick()}>Alert Data(Question)</button>;
}
handleClick()を複数の関数に分割して書き直せ。
なお、このCodeSandboxに書かれているQuestionコンポーネントが上記のコンポーネントに当たる。(解答例のコンポーネントが同sandboxのAnswerコンポーネントになるので事前に見ないことをおすすめする)
※package.jsonの内容は以下である。
{
  "name": "react-typescript-axios-test",
  "version": "1.0.0",
  "description": "",
  "keywords": [],
  "main": "src/index.tsx",
  "dependencies": {
    "axios": "1.3.4",
    "react": "18.0.0",
    "react-dom": "18.0.0",
    "react-scripts": "4.0.3"
  },
  "devDependencies": {
    "@types/react": "18.0.25",
    "@types/react-dom": "18.0.9",
    "typescript": "4.4.2"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  },
  "browserslist": [
    ">0.2%",
    "not dead",
    "not ie <= 11",
    "not op_mini all"
  ]
}
問題4 一度にひとつのことを
データベースから日本の町をランダムに取得する関数extractRandomTown()がある。
返り値の型はextractRandomTownResだ。
type extractRandomTownRes = {
	prefecture: string;
	city:string;
	ward:string;
	town:string;
}
例えば、以下のような返り値があり、データベースの不具合で、市や区が本来あるにもかかわらずundefinedで返ってくることもある。(都道府県と町はundefinedではなく必ず文字列が返ってくる)
{
	prefecture: '東京都',
	city: undefined,
	ward: '目黒区',
	town: '青葉台'
}
{
	prefecture: '鹿児島県',
	city: '鹿児島市',
	ward: undefined,
	town: '小松原'
}
{
	prefecture: '鹿児島県',
	city: undefined,
	ward: undefined,
	town: '東郷'
}
extractRandomTown()の返り値を要約して「目黒区、青葉台」や「鹿児島県、鹿児島市」等と2単語を読点で連結した文字列を返す関数summarizeTown()を記載しようとしている。2単語の選び方は次のような決まりになる。
- 基本的には、前半の単語はprefectureになる。後半の単語はcityが第一優先だが、もしundefinedの場合は、ward→townの順で使用可能なものを使う
- 東京都の場合、前半の単語はcity→wardの順で使用可能なものを使う。cityとwardともにundefinedの場合はprefectureを使う。後半はtownを使用する
- cityの値が政令指定都市の場合、前半の単語はcityとなる。後半の単語はward→townの順で使用可能なものを使う
政令指定都市かを判別して、true, falseのいずれかを返すvalidateOrdinanceDesignatedCity()があるとして、summarizeTown()を完成させよ。
type extractRandomTownRes = {
	prefecture: string;
	city:string;
	ward:string;
	town:string;
}
const validateOrdinanceDesignatedCity = (city: string)=> {
    const ordinanceDesignatedCities = [
        '大阪市',
        '名古屋市',
        '京都市',
        '横浜市',
        '神戸市',
        '北九州市',
        '札幌市',
        '川崎市',
        '福岡市',
        '広島市',
        '仙台市',
        '千葉市',
        'さいたま市',
        '静岡市',
        '堺市',
        '新潟市',
        '浜松市',
        '岡山市',
        '相模原市',
        '熊本市',]
    return ordinanceDesignatedCities.includes(city)
}
const summarizeTown = (townObj: extractRandomTownRes) => {
	//...
	// return 2単語を読点で連結した文字列
} 
問題1-4の解答例
問題1 解答例
三項演算子を使用し続けるならば、まず最初の条件を ≠2020という例外処理に変更するのが良いです。
interface User {
	id: number;
	cYear: number;
	hasJoinedCampaign: boolean;
}
const changeHeroImage = (user: User) => {
	const changeHeroImage = (user: User) => {
  return user.cYear !== 2020
    ? "/img/normal"
    : user.hasJoinedCampaign
    ? "/img/specialParticipants"
    : "/img/campaign";
};
};
最初の条件を == 2020のままで書くと、返り値(画像パス)がまず一つ分かるまでの時間が長くなってしまいます。また、cYearとhasJoinedCampaignの値を両方覚えておかないといけなくなり難読化する。以下が難読化してしまっている例です。
interface User {
	id: number;
	cYear: number;
	hasJOinedCampaign: boolean;
}
const changeHeroImage = (user: User) => {
	return user.cYear === 2020
    ? user.hasJoinedCampaign
      ? "/img/specialParticipants"
      : "/img/campaign"
    : "/img/normal";
};
このように、すでにある条件分岐に新しい条件を加えるときには、先に例外的な処理やもともとequalで繋いでいたものをnot equalに変更することを検討した方が良いです。
また、三項演算子でわざわざ複雑な条件分岐を表すよりも通常のif文を使用した方が読みやすい場合もあります。
interface User {
	id: number;
	cYear: number;
	hasJoinedCampaign: boolean;
}
const changeHeroImage = (user: User) => {
  if(user.cYear !== 2020){
		return "/img/normal"
	}
  if(user.hasJoinedCampaign){
		return "/img/specialParticipants"
	}
  return "/img/campaign";
};
問題2 解答例
配列に同じ値の要素が複数あった場合に複数削除するのか、最初の一つだけ削除するのかが分からないという問題点があります。今回の場合、最初の要素のみを削除するのでその点をコメントで記載すると良いです。
また、不要な変数は中間結果を保持しているindexToRremoveである。結果をそのまま使えば、変数を削除できます。
解答例としては以下。
// 配列の要素のうち、値が一致した最初のものを削除する
// 例. removeValFromArr<string>(['a', 'b', 'a'], 'a')は['b', 'a']を返す
const removeValFromArr = <T>(arr: T[], valToRemove:T) => {
  for (let i = 0; i < arr.length; i += 1) {
    if (arr[i] === valToRemove) {
      arr.splice(i, 1)
      break;
    }
  }
};
問題3 解答例
このコードの目的は「API通信して返ってきたレスポンスを処理してポップアップ表示する」ことであるため、「レスポンスの処理」は無関係の下位問題といえます。レスポンスの処理とは具体的には、オブジェクトをきれいにformat化して印字することです。
そこで、まず、以下の様にオブジェクトを文字列に変換する関数を作って下位問題を切り出すことを考えます。
const __convertObjToStr = (obj: {[key: string]: unknown}, indent: string)=>{
    let str = "{\n";
            for (const [key, value] of Object.entries(obj)) {
              str += indent + key + " : " + value + "\n";
            }
            return str + "}";
}
※{[key: string]: unknown}とは任意の文字列をkeyに持つオブジェクトを表す型。
https://qiita.com/standard-software/items/843d7bdcc6fc37aca484
しかし、この関数には以下のような問題があります。
- keyが文字列ではないオブジェクトである配列の場合に対応していない
- ネストが深いオブジェクトの場合に対応していない
- APIの返り値が常にオブジェクトとは限らない。undefinedやnull、string、その他の型の場合に対応していない
これらの課題に対処したものとして以下が解答例になります。
import axios from "axios";
export function Answer() {
  const __convertObjToStr = (obj: unknown, indent: string) => {
    if (obj === null) return "null";
    if (obj === undefined) return "undefined";
    if (typeof obj === "string") return obj;
    if (typeof obj !== "object") return String(obj);
    if (Array.isArray(obj)) {
      let str = "[\n";
      obj.forEach((ele, i) => {
        str +=
          indent + i + " : " + __convertObjToStr(ele, indent + indent) + "\n";
      });
      return str + indent + "]";
    } else {
      let str = "{\n";
      for (const [key, value] of Object.entries(obj)) {
        str +=
          indent +
          key +
          " : " +
          __convertObjToStr(value, indent + indent) +
          "\n";
      }
      return str + indent + "}";
    }
  };
  const handleClick = () => {
    axios
      .get("https://jsonplaceholder.typicode.com/users")
      .then((res) => {
        const resStr = __convertObjToStr(res.data, "   ");
        alert(resStr);
      })
      .catch((err) => console.log(err));
  };
  return <button onClick={() => handleClick()}>Alert Data(Answer)</button>;
}
CodeSandboxのAnswerコンポーネントに上記のコードを書いています。
今回の問題が示すように、下位問題を別の関数に分割した場合には、
- 機能追加しやすくなる
- 読みやすさの向上
- エッジケースの処理が楽になる
- テスト文を書きやすくなる
等、様々なメリットがあります。
問題4 解答例
東京都かどうかや政令指定都市かどうかで条件分岐する前にどのようなタスクがあるのかを整理します。
- パラメーターから都道府県等の値を取得する
- 前半部分の単語について優先順位に基づいて決定する
- 後半部分の単語について優先順位に基づいて決定する
- 読点で連結して返す
そのうえで、2番目と3番目のタスクについて条件分岐が必要だということが分かります。
以下のコードが解答例です。
type extractRandomTownRes = {
	prefecture: string;
	city:string;
	ward:string;
	town:string;
}
const validateOrdinanceDesignatedCity = (city: string)=> {
    const ordinanceDesignatedCities = [
        '大阪市',
        '名古屋市',
        '京都市',
        '横浜市',
        '神戸市',
        '北九州市',
        '札幌市',
        '川崎市',
        '福岡市',
        '広島市',
        '仙台市',
        '千葉市',
        'さいたま市',
        '静岡市',
        '堺市',
        '新潟市',
        '浜松市',
        '岡山市',
        '相模原市',
        '熊本市',]
    return ordinanceDesignatedCities.includes(city)
}
const summarizeTown = (townObj: extractRandomTownRes) => {
	const {prefecture, city, ward, town} = townObj;
	let firstHalf = prefecture;
	let secondHalf = city || ward || town;
	if(prefecture === '東京都'){
	    firstHalf = city || ward || prefecture;
	    secondHalf = town;
	    return firstHalf + '、' + secondHalf;
	}
	if(validateOrdinanceDesignatedCity(city)){
	    firstHalf = city;
	    secondHalf = ward || town;
	}
	return firstHalf+'、'+secondHalf;
}
console.log(summarizeTown({
	prefecture: '熊本県',
	city: '熊本市',
	ward: '中央区',
	town: '帯山'
}))
===============
解説は以上です!
Javascript/Typescriptの問題集についてはこの記事を持って終了となります。
時間ができたらコーディング規約に関する別の書籍を読んだり、別の言語を習得したりして新しい問題集を公開しようと思います!
よい記事だと思ったかたは、いいねやストックをぜひお願いします!