グリオ君と考えるJavaScriptドリル 第1回 最大値や合計を求める

プログラミング入門者のグリオ君が、JavaScriptの関数の問題を解きます。今回は、与えられた数値の配列から最大値を返す関数を作成する問題と、複数のオブジェクトから特定のプロパティの値を合計する関数を作成するという問題です。

発行

  • 杉浦 有右嗣
  • 外村 奈津子
グリオ君と考えるJavaScriptドリル シリーズの記事一覧

はじめに

このシリーズは、JavaScript入門者が、コードの書き方を学んでいくものです。登場人物は次の2人です。

  • 先生
    このシリーズにおけるJavaScriptの達人の先生。実務では、クライアントにとってちょうどいいエンジニアリングを日々探求しているエンジニア。やる気のない生徒には、やや冷たい。最近の推しJSメソッドはObject.freeze()Proxy

  • グリオ君
    プログラミング入門者。JavaScriptは学んでいるのでコードは少し書けるし、ググることも慣れてきた。ChatGPTにも興味津々。「え、もしかして、あんまりわかっていなくてもコード書けるんじゃね!?(やったー!)」

前の授業ではミニアプリ制作をがんばったグリオ君でしたが、さてさて……。

グリオ:「グリオ君、JavaScriptの勉強しなよ!」って、簡単な関数を書くJavaScriptドリルのお題をもらったので、今回は、ChatGPTを使ってコードを書いてもらいました。題して「JavaScriptドリル feat. ChatGPT 4o」です。

ChatGPTにわからない用語を聞いたりすると、けっこうちゃんと答えてくれますよね。コードを生成してもらうのも、複雑じゃなくて、簡単なものなら大丈夫かなって思ったんです。

先生:グリオ君の勉強に、って出された問題でしょう?(それで自分の勉強になってるんですかね? とはいえ、これからの時代は生成AIとの付き合い方も重要になりそうなので、まあ見てあげるとしますか)

で、どういうふうにコードを書いてもらったんですか?

グリオ:課題文と目的と要件を入れて答えてもらいました。いちおう動くかどうかだけ試して、動いていたので大丈夫そうかなと思ったのですが、もっといい書き方があったりするのかなって思って……。先生に見てもらいたいなあ、って。

先生:動くかは確認したけど、自分で書いたコードではない、と。わかりました。ちょっと見てみましょう。

グリオ:ありがとうございます。できれば、そのままコピペして使えたらいいなって。

課題1:配列の最大値を求める関数

グリオ:第一問目です。

課題1:配列の最大値を求める関数

目的:与えられた数値の配列から最大値を返す関数を作成する。

要件

  • 関数名: findMaxValue
  • 引数: 数値の配列
  • 戻り値: 配列内の最大値

先生:たとえば学校で数学のテストがあったとして、そのテストでクラスの最高点を求めるとか、実務でも見かけそうな処理ですね。

グリオ:そうなんですね。それでChatGPTに書いてもらったのが、次のコードです。コメントも一緒に書かれていたので、何をやっているか、わかりやすかったです。

コードでは、最大値を設定して、その最大値と各要素を順番に比べていって、もし今の最大値より大きなものがあれば、その値で最大値を更新するってことをやっています。

コードの全体

function findMaxValue(arr) {
  let maxValue = arr[0];  // 最初の要素を最大値として設定

  for (let i = 1; i < arr.length; i++) {
    if (arr[i] > maxValue) {
        maxValue = arr[i];  // より大きい値が見つかった場合、最大値を更新
    }
  }

  return maxValue;  // 配列内の最大値を返す
}

// 使用例
const numbers = [3, 5, 7, 2, 9];
console.log(findMaxValue(numbers));  // 出力: 9

2つの要素を比べる関数

先生:まずこのforで回して、1つ1つの要素を見ていくのは、絶対必要になりますよね。どんな要素があるかわからないから、全部を見る必要はあるわけです。

改良の余地があるのは、次のif文ですね。2つの数値を比べるにはMath.max()という関数も使えます。

Math.maxに置き換え

  for (let i = 1; i < arr.length; i++) {
    maxValue = Math.max(maxValue, arr[i]);
  }

これは引数に渡された数値のうち、最大の値を返してくれる関数です。

グリオ:関数の名前もわかりやすいし、便利ですね。そんな関数があったとは。

先生:実はこの関数は引数がいくつもとれるんです。別に2つだけってことはないんですね。

引数はいくつでも取れる

console.log(Math.max(1, 2));  // 2
console.log(Math.max(3, 1, 2)); // 3
console.log(Math.max(3, 10, 1, 3, 2)); // 10

配列をほどく

グリオ:ん! 先生、ひらめきました。findMaxValue()なんていう関数はもしかして不要なのかも。引数に直接配列を入れちゃえばいいのではないですか? よし、やってみます。

先生:なんですか、そのひらめきは(笑)。

Math.maxに配列を直接渡すというひらめき

console.log(Math.max([1, 2, 3, 4, 5]));  // なんですか?

グリオ:あ、ダメだ。確かに、NaNでした。

先生NaNですよね。

グリオ:先生、うまい。

先生:いや、そういうことではなく。Math.max()に引数として渡せるのは数値だけです。数値ではないものが渡された場合は、NaNを返すのがこの関数の仕様です。なので、ここでは数値の配列を渡しているので、NaNになります。こういう場合は、スプレッド構文を使いましょう。

グリオ:スプレッド構文……。てんてんてん、でしたっけ? ...だ。えーと、これは配列の要素を展開して、ほどいてくれるのですね。

先生:そう、だから、グリオ君のひらめきは、半分正解でした。

Math.maxの使用

const numbers = [3, 5, 7, 2, 9];
console.log(Math.max(...numbers)); // 9

ただ、こうする場合、配列に要素が存在しない場合には注意してください。

Math.max

console.log(Math.max(...[])); // -Infinity

ちなみに、CodeGridのドリル記事でもスプレッド構文を使って解くものがありますね。今度は自分で解いてみてくださいね。

グリオ:……はい。

課題2:オブジェクトのプロパティを合計する関数

グリオ:次の問題です。複数のオブジェクトから特定のプロパティの値を合計する関数をつくってくださいというものです。

先生:たとえば、国語、算数、英語、社会、理科のテストの点数があったときに、総合点を算出するというような話ですね。

合計点を算出する(scoreプロパティ値の合計)

// Aの成績
[{ kamoku: "kokugo", score: 50 }, { kamoku: "sansu": score: 70 }]

課題2:オブジェクトのプロパティを合計する関数

目的: 複数のオブジェクトから特定のプロパティの値を合計する関数を作成する。

要件

  • 関数名: sumProperty
  • 引数: オブジェクトの配列、および合計したいプロパティの名前(文字列)
  • 戻り値: プロパティの値の合計

グリオ:こんなコードを書いてもらいました。

コードの全体

function sumProperty(objects, propertyName) {
  return objects.reduce((total, obj) => {
    // プロパティが存在するかチェックし、存在すれば合計に加える
    if (obj.hasOwnProperty(propertyName)) {
      return total + obj[propertyName];
    }
    return total;
  }, 0);
}

// 使用例
const data = [
  { name: 'Alice', age: 25 },
  { name: 'Bob', age: 30 },
  { name: 'Charlie', age: 35 },
];

const totalAge = sumProperty(data, 'age');
console.log(totalAge); // 出力: 90

使うメソッドを揃える

先生:問題もそうですが、この関数は課題1と同じようなことをしていますよね。さきほどは配列内の要素の最大値、今回は要素がオブジェクトで、そのオブジェクトに存在する特定のキーを使って合計を出したい。うーん、なるほど。今度はArray.prototype.reduce()を使っているんですね。

グリオ:はい。これはCodeGridで習ったもので、配列の要素を1つ1つ処理して、1つの要素の処理が終わったあとの値を、次の処理の初期値にしてくれるんですよね。頭良さそうでいいかんじだと思いました。

先生:「頭がよさそうに見えるか」はコードを書くときに重要なことではないですよ。もしこのコードをレビューに出したとすると、「課題1ではforを使ってループを処理したよね? なぜこちらはreduceなんですか?」と聞かれると思います。

グリオ:えーと、意図はないです。同じことができるなら、どっちを書いてもいいのかと思ってました。同じ処理をするなら、同じ書き方じゃないといけないですか?

先生:「いけない」ということはないのですが、もし、違うものを使うなら、使い分けの理由があるのかな? と考えるのが、エンジニアです。同じ人が書いたコードという前提もありますし、特別な理由がないのであれば揃えてください。どちらを使うは好みの問題もありますが、私ならさきほどと同じforにします。

forで書き直し

function sumProperty(objects, propertyName) {
  let total = 0;
  for (const obj of objects) {
    // プロパティが存在するかチェックし、存在すれば合計に加える
    if (obj.hasOwnProperty(propertyName)) {
      total += obj[propertyName];
    }
  }
  return total;
}

グリオ:わかりました。

グリオの聞き取りノート:先生がforを使う理由

Array.prototype.reduce()は、使い方が特徴的です。reduce(total, obj)と、第一引数に初期値、第二引数に各オブジェクトがくるというルールや、一回の処理が終わることに、必ず終了段階のtotalを返さなければならない、途中でbreakしたりスキップができないなど、いくつかのルールを覚える必要があります。

また、reduce()は主に合算する用途にしか使えませんが、forはそれ以外のどんな用途にも使えます。

不要なチェックをしていないか

先生:動作には問題がないのですが、コードとして気になるところがあと一箇所あります。プロパティのチェックの部分です。ここでやりたいのは、ageというプロパティがあるかどうかですか?

プロパティの有無のチェック

// プロパティが存在するかチェックし、存在すれば合計に加える
if (obj.hasOwnProperty(propertyName)) {

グリオ:えー? 気になります? 僕は、なんだか気が利いているな、って思いました。

先生:それは要件次第なんですよ。要件になくて、不要なチェックしていたとすると、意味がありません。今回のケースだと、元データは自分で用意していますよね。その場合、ageが存在しないわけがないのでは?

グリオ:はい。ただもし、ageがない可能性があるという要件だとすると、困っちゃいますよね。

先生:そうですね。存在していないと困ります。でも、それはこの関数ではなく、この関数以前の別のところで担保する話かなとも思います。

お題や要件の指示が悪いといえばそうなのですが、もし、「ageがない場合がある」ということであるなら、「ageがない場合はどうするのか」という要件もあわせて決めておかないと、コードで表現できません。

0として扱うのか、その要素自体をスキップするかなど、その要件によっては結果が変わってきます。今回の要件が合計ではなく平均を出すものだった場合、思わぬバグになる可能性もあります。

[7, 8, 0]の平均は5ですが、[7, 8, /*skip*/]の平均は7.5です。

TypeScriptでの型チェックをもとに考える

先生:今はJavaScriptで書いていますけれど、もしTypeScriptであれば、どういう型のデータなのか明示することになるので、必ずその可能性を考慮することにもなります。

グリオ:TypeScript。JavaScriptのような動的な型付けではなく、静的な型付けをする言語ですよね。

先生:そうですね。たとえば、こんなふうに定義されているとします。

引数の型付け

/**
 * @param {Array<{ name: string; age: number}>} objects
 */

これは、引数がnameというstring型と、ageというnumber型のプロパティを持つオブジェクトの配列であることを示しています。こうやって定義されていると、nameageというキーは、絶対に存在することが前提になっているとわかります。

グリオ:もしageがない可能性がある場合は、どう定義するんですか?

先生:ないかもしれないという定義は?をつけて表現します。

ageというプロパティはないかも?

/**
 * @param {Array<{ name: string; age?: number }>} objects
 */

そして、こういう定義の場合に、total += obj.ageというような操作をすると、エディタ上でも表示がでますし、コンパイルするとエラーになることもあります。なぜなら、ageがないかもしれないし、数値じゃない可能性があるのに、足し算はできないからです。

ageがないかもしれないのに足し算

// @ts-check
/**
 * @param {Array<{ name: string; age?: number}>} objects
 */
function sumProperty(objects){
  let total = 0;
  for (const obj of objects) {
    total += obj.age; // 'obj.age' is possibly 'undefined'.
  }
  return total;

グリオ:あ、ほんとだ。「obj.ageundefinedになる可能性がある」ってエディタに表示された。

先生:TypeScriptで書くと、こうやって暗黙の要件が明示される場合もあるわけです。ageがないこともあるし、numberじゃなくて、stringかもしれないなど。こういった要件があるなら、プロパティ有無のチェックはあってもいいでしょう。

グリオ:じゃあ、プロパティ有無のチェックをするとして、最初のコードで大丈夫ですか?

先生hasOwnProperty()を使う他にも、バリエーションが考えられます。

ifの書き方バリエーション

// プロパティが存在するかどうかだけチェック
if (propertyName in obj) {
  // ...
}

// プロパティが数値型として存在したら
if (typeof obj[propertyName] === "number") {
  // ...
}

in演算子は指定されたプロパティが指定されたオブジェクトにあるかどうかを簡単にチェックできます。typeofは少し違う発想ですね。今回は「足し算」をするという要件なので、渡されるデータは「数値」じゃないといけませんよね。なので、データの型が「数値型」であるかどうかをチェックするという発想です。

要件によって使い分けてほしいのですが、今回の問題であれば、typeofを使ったチェックが個人的にはおすすめです。

グリオ:なるほど。これらのほうが読みやすい気がします。

先生:今回はここまでにしましょうか。

今回のグリオ君の感想

グリオ:先生は、コードの処理を読んでいるだけじゃなくて、こういう処理をしているということは、仕様はこうなっているのか? っていつも考えているんですね。コードから仕様に想いをめぐらすのかぁ。

先生:そうですね。事前に仕様や要件を整理することで、コードをより簡潔にできることもよくありますし、より堅牢な実装になります。また、そこが整理できていないまま生成AIを使っても、思うような結果は得られません。

グリオ:事前に整理ができる日はいつくるんだろ。次回もがんばります!(ChatGPTが!)

杉浦 有右嗣
PixelGrid Inc.
シニアエンジニア

SIerとしてシステム開発の上流工程を経験した後、大手インターネット企業でモバイルブラウザ向けソーシャルゲーム開発を数多く経験した。2015年にピクセルグリッドへ入社し、フロントエンド・エンジニアとして数々のWebアプリ制作を手掛ける。2018年に大手通信会社に転職し、低遅延配信の技術やプロトコルを使ったプラットフォームの開発と運用に携わっていたが、2020年ピクセルグリッドに再び入社。プライベートでのOSS公開やコントリビュート経験を活かしながら、実務ではクライアントにとって、ちょうどいいエンジニアリングを日々探求している。

外村 奈津子
PixelGrid Inc.
編集

情報出版社に在籍中、Mac雑誌、中高年向けフリーペーパー、コラムサイト運営、健康食雑誌、グラフィック・Web技術書籍編集、IT系ニュースサイトの編集記者を経験。その後フリーランスのエディター/ライターとして独立。2011年4月より株式会社ピクセルグリッドへ入社。ピクセルグリッドが提供するフロントエンド技術情報を提供するサービス「CodeGrid」の編集を担当している。

Xにポストする Blueskyにポストする この記事の内容についての意見・感想を送る

全記事アクセス+月4回配信、月額880円(税込)

CodeGridを購読する

初めてお申し込みの方には、30日間無料でお使いいただけます