FRTKL

失意泰然、得意冷然

PHPDocにおける @throws の使い方 2021 ver.

PHPDocには @throws という表記でそのメソッドが投げ得る例外を文書化することができます。
今回はその個人的使い方の方針をまとめます。2021年バージョンです。

※ 一度社内に展開してガラッと見解が変わったのでそれを反映させてます

そもそも、PHPDocの @throws とは

  • @throwsタグは、メソッドが特定のエラー/例外を投げる可能性があることを示すために使用できます
/**
 * データベースに接続する
 *
 * @param string $dsn 接続情報
 * @return bool 接続可否
 * @throws RecoverbleDbException 接続に一時的に失敗した場合にthrowされる
 */
function connect($dsn)
{
    // something...
}

https://zonuexe.github.io/phpDocumentor2-ja/references/phpdoc/tags/throws.html

PhpStormで @throws はどのように扱われるか

PhpStorm 2017.3

  • 新しい例外解析エンジンを搭載し、処理されない例外を見つけたり、書き漏れている @throws タグを見つけたりなど出来るようになった

PhpStorm 2018.1

  • 解析対象から外すべき例外のリストを設定できるようになった
    • Preferences -> Languages & Frameworks -> PHP -> Analysis -> Unchecked Exceptions
    • 初期値では以下の3つを除外している. 継承先の例外も除外される点に注意*1
      • \RuntimeException
      • \Error
      • \LogicException
        • f:id:fortkle:20211221123426p:plain

エラー表示

PhpStorm は、呼び出したメソッドの PHPDoc の @throws をヒントに、try-catch の不足などをインスペクションすることができます。

例: @throwsに記載されている例外のハンドリングが不足していたケース

f:id:fortkle:20211221124801p:plain

例2: メソッドの内部でthrowされている例外が@throwsに書かれていないケース (除外設定されている場合は警告は出ない)

f:id:fortkle:20211221124843p:plain

@throws に関する問題点 in PhpStorm

テストでReflectionClassなどを使うと黄色い警告が出てしまう

  • テストケースの中でReflectionクラスを使うことはPHPだと比較的よくある
  • このときに ReflectionException (\Exceptionの派生クラス)のtry-catchが不足していると警告が出てしまう
  • しかしここで例外が来ることを想定して捕捉する必要性は薄い
    • 捕捉してできることもほぼないし、実装ミスでなければ生じ得ない
  • なので @noinspection を使うことで許容
    /** @noinspection PhpUnhandledExceptionInspection */
    $clientProp = new ReflectionProperty($this->logger, 'client');

外部ライブラリなどを使ったときに黄色い警告が出てしまう

  • 非検査例外のつもりで、\Exceptionを継承したユーザー独自例外を投げてきたりするライブラリがある
    • ユーザー独自例外は捕捉必須であるとPhpStormに怒られるが、非検査例外なので捕捉しても意味がない
  • これは現状のPHPコミュニティにおいて例外の意味するところに宗派があるのが原因の1つ
    • PHPの例外はすべて非検査例外 v.s. RuntimeException以外は検査例外など
  • 仕方ないと割り切るか、別の設計のライブラリを使う

\Error や \Throwableなどを使うと黄色い警告が出てしまう

  • 基本的にこれら2つを直接アプリケーションから使用するのは避けるべき (理由)
  • しかし、「どんな例外が起きても必ず後始末として実行したい処理」などのために catch(\Throwable $t) { ...後処理; throw $t; } とすると、PhpStormが@throwsを書け! と警告を出してしまう
  • これも仕方ないと思って許容
    • このために @noinspection をしはじめると際限がなかったり、 \Error や \Throwable を除外例外の設定に入れてしまうとその派生クラスまでインスペクションが動かなくなるのでできれば避ける

結論:@throwsの使い方 2021年ver.

  • @throws は仕様・ドキュメントであって、実装を強制するものではないスタンス
    • @throws に書いてあるものは必ず呼び出し元がtry-catchなどでハンドリングしろ!」とはしない
    • @thorws に必ず書くのはこれ!書いちゃいけないのはこれ!」みたいに強いルールは決めない
  • 基本的には起こりうる例外を列挙していく
    • ただし仕様としてあまり意味をなさないものは書かなくても良い(メソッド内の更に内部の深いところで発生する例外など)
  • 仕様を表現するときに、以下のようなケースでは書いておいたほうがいいので @throws に記載することを推奨
    • 呼び出し側で必ず捕捉してほしい例外
    • 知っておいてほしい仕様を表現するもの(非検査例外や捕捉しても意味が薄いものであっても)
      • 非検査例外
        • PHP7におけるError
          • TypeError, ParseError, AssertionError, DivisionByZeroErrorなど
        • LogicException
          • BadFunctionCallException, InvalidArgumentException, OutOfRangeExceptionなど
      • 捕捉しても意味が薄いもの
        • \Exception, \Throwable

*1:例えばHttpExceptionなどCakePHPの独自例外は全てRuntimeExceptionを継承しているので、初期状態でこれらは @throws のインスペクションが実行されない

持続的な1on1をするために気をつけていること

この記事は コネヒト Advent Calendar 2021 4日目 の記事です。

はじめに

最近のコネヒトでの近況を書くと、もともとエンジニアとして入社していてちょっと前まではEMのような働き方をしていましたが、今はプロダクト開発を行う1部門のマネジメントのロールをがっつり担当しています。

タイトルにもある通り、部門の全メンバーは15人ほどで全員と1on1をやっているのですが、たまに別の部署の人や同僚から「15人と1on1するの大変だし疲れたりしませんか?」といった質問を貰うことがあって、自分としては少しずつ慣れてきてあまり負担に思わなくなってきたため、そういった少なくない人数と1on1をする人向けに、私が気をつけていることを書いてみようと思います。

宣伝

以前、同僚マネージャーが会社のテックブログに1on1について記事を書いていて今でも有用だと思うのでぜひ読んでみてください。

それでは、さっそく本題に入っていきましょう。私が気をつけていることを2つ、取り上げたいと思います。

1. 1on1を日常の場にする

気をつけていること1つ目は「1on1を日常の場にする」ことです。ここでは主に自分にとって日常の場にすることを指しています。*1

ではどういうものが日常の場でない、非日常の1on1なのか?というと、例えば四半期に1度や半年に一度の1on1だったり、めちゃくちゃ準備に時間がかかる1on1だったり、普段ほぼ話したことがない人との1on1だったり、そういったものです。

15人と1on1をすると想像以上に自分の作業時間が減っていきます。仮に1人あたり30分を隔週(月2回)で行うと15人のメンバーがいる人なら月に15時間を1on1に割いていることになります。これが非日常の1on1なんだとしたら準備時間などの+αの時間が更に乗っかってきて大変になりそうですよね。

できる限り継続的に1on1を運営していくためにも、1on1が大変になりすぎないように気をつけています。ここでは、そのために私が気をつけている具体的なTipsを取り上げてみます。

① しっかりと定期的に開催すること

「今週はMTGがたくさんあって忙しい」「1on1の時間はメンバーの時間を奪っているから減らしたい」などそれらしい理由をつけて1on1をスキップしたり、不定期に実施していたりしていませんか? あるいはメンバーとの1on1を繰り返し予定で抑えていたけど、実は有給を取っていた日とかぶっていたことに直前で気づき翌週にリスケして・・・みたいなことを繰り返していたら気づいたら1on1がフェードアウトしていったり。

これを防ぐには「回転方式」でスケジュールを抑えるのがおすすめです。これは『HIGH OUTPUT MANAGEMENT』で知ったので引用します。

ワン・オン・ワンは回転方式で次の予定を立てるのがよい。つまり、今やっているミーティングが終わる前に次のミーティングを決めるというように計画すべきである。そうすれば他の約束のことも勘案に入れられるし、キャンセルするようなこともなくなる。
HIGH OUTPUT MANAGEMENT』 p.134

私の場合、1on1の一番最初に次回の日程を押さえています。カレンダーを開きながら日程を調整しつつ、軽く雑談してアイスブレイク代わりにするのがおすすめです。
このやり方にしてからスケジュールの調整がかなり楽になりました。

② 無理をしないこと

2つめなのにもう具体的でなくなってしまいましたが()、次におすすめなのは「無理をしないこと」です。
マネージャーとして1on1をお願いする側になってから3年ほどが経ちましたが、最初はいろんな1on1の本を読んで意識が高まり以下のような状態になっていました。

  • 本質的な会話を引き出さなくては
  • ネクストアクションを決めなくては
  • 本人のモチベーションや成長につながる時間にしなくては

もちろん、毎回の1on1をこういった場にできるのであれば最高ですし、1on1の達人ならもしかしたらできるかもしれません。
しかし、例えば私のように自分よりもキャリアの長いメンバーと1on1をすることがあったり、頻度多めに毎週1on1をしていることがあったりするのであれば、もっと肩肘張らずに楽しい時間にすることを優先するのがいいかもしれません。
タスクの進捗報告や愚痴や不満を聞く場、雑談の場になっても全然いいと思っています。毎回ホームランを狙うのではなくて、たまにヒットを打つぐらいでいい、なんなら打席に立ち続けることの方が大事かもしれません。

2. 1on1を発見の場にする

「1. 日常の場にする」の次、気をつけていることの2つ目は「発見の場にする」ことです。

何の発見か?というと、解決すべき重要な組織課題のタネだったり、各メンバーの知らなかった特性だったり、成長のポイントだったり、そういったものです。
1つめで書いたとおり日常の場にすることも大事なのですが、慣れてきたら徐々に発見の場にできるようにしていきましょう(私はまだ全然ですが...)。

最初の頃は「1on1を日常の場にするぐらいなら、いっそやめて時間を自由に使えるようにしたほうがいいのでは?」と思っていた時期もあったのですが、今はそうは思っていません。
なぜなら、例えば部門の全メンバーの業務効率を上げることができる重要な課題に気づける可能性があったり、各メンバーの特性に合わせて組織づくりができるようになったりすることで、使った時間(月に15時間)の何倍も業務効率化などの成果につながる可能性があるからです。

発見の場にするために私が意識している具体的なTipsを取り上げてみます。

①傾聴

冒頭であげたテックブログの記事にある1on1シートや、事前の1on1のリマインドなどを活用して、できる限り話すトピックを事前にメンバーに考えてきてもらうようにしています。また、そのトピックをしっかりと聞くように意識しています。
様々なトピックを話すことは発見のための重要な要素なので、仮に共感できなくても持ってきてくれたアジェンダを受け入れ理解することを一番大事だと思って、メンバーが話しやすい環境を作っていきましょう。

ただ... 気づいたら自分がめちゃくちゃ喋っていることが多いので反省しています😇

②全力で応えること

次に「全力で応えること」を意識しています。これは自分の能力と関係なく唯一誰でもできる誠意ある対応です。
いろいろな1on1の書籍を読みましたが、比較的「ベテラン上司と新人社員」という構図での1on1が多く、上司の方が答えを知っていてそこにいかに導くか、自分で気づいてもらうかという前提で話が進むものが多いなと感じています。
しかし、私のような状況だとむしろ答えがわからないことの方が多く、一緒に悩んだり、生煮えの答えを出して意見を貰ったりすることが多くなります。そのような状況において「この人は自分がぶつけた質問・相談・悩みに対して、全力で応えてくれている」と思ってもらえるかどうかは、その後の1on1の充実さと非常に強い相関があると思うので、とても意識しているポイントです。

こういった姿勢がお互いの信頼関係の醸成に繋がり、話しづらい話もできるような関係性が生まれ、それが巡り巡って新たな発見につながると考えています。

以上、15人と有意義な1on1をするために気をつけていることでした!

*1:もちろん1on1に参加する双方にとって日常の場になることも大事

DateTime::diff()とCarbon::diffInMonths()のバグっぽい挙動と対策

この記事は コネヒト Advent Calendar 2020 12日目 の記事です。

目次

混乱しがちなPHPのDateTime::diff()、Carbon系のライブラリにあるdiffInMonths()、にまつわる問題について、それぞれ挙動になっている理由を示しながら紹介します!(もともと社内向けに公開していた記事を加筆・修正しています)

問題リスト

  1. UTC環境下でdiffInMonths()の結果がおかしい
  2. Asia/Tokyo環境下でdiff()の結果がおかしい

1. UTC環境下でdiffInMonths()の結果がおかしい

問題の挙動

  • 2019/02/01と2019/03/01のdiffは1ヶ月
  • 2019/01/31と2019/03/01のdiffは0ヶ月 ← !?
  • 2019/03/01と2019/01/31のdiffは1ヶ月 ← !? (0じゃないの...?)

再現コード

<?php

$testcases = [
    ['2019-02-01', '2019-03-01'],
    ['2019-01-31', '2019-03-01'],
    ['2019-03-01', '2019-01-31'],
    ['2019-01-31', '2019-03-02'],
    ['2019-01-31', '2019-03-03'],
];

foreach ($testcases as [$since, $until]) {
    $tz = new DateTimeZone('UTC');
    $since = new DateTimeImmutable($since, $tz);
    $until = new DateTimeImmutable($until, $tz);

    // Carbon::diffInMonths()を標準関数だけで表現 https://github.com/briannesbitt/Carbon/blob/ca197a5e31acb17e1ac6fe2708b65e367534ec1b/src/Carbon/Traits/Difference.php#L174-L179
    $diff = $since->diff($until, true);
    $diffInMonths = $diff->format('%r%y') * 12 + (int) $diff->format('%r%m');

    echo sprintf("%sから%sまでのdiffInMonths()は%d (%s)", $since->format('Y-m-d'), $until->format('Y-m-d'), $diffInMonths, $diff->format('%mヶ月 %d日 総日数 %a日')), PHP_EOL;
}
$ php diffInMonths.php
2019-02-01から2019-03-01までのdiffInMonths()1 (1ヶ月 0日 総日数 28)
2019-01-31から2019-03-01までのdiffInMonths()0 (0ヶ月 29日 総日数 29)
2019-03-01から2019-01-31までのdiffInMonths()1 (1ヶ月 1日 総日数 29)
2019-01-31から2019-03-02までのdiffInMonths()0 (0ヶ月 30日 総日数 30)
2019-01-31から2019-03-03までのdiffInMonths()1 (1ヶ月 0日 総日数 31)

原因

  • 正確には「UTC環境下でdiffInMonths()の結果がおかしい」ではなく「UTC環境下で3/1と3/2のdiffInMonths()の結果がおかしい」
    • ただし、比較対象となる日付の基準点によって結果が変わる
  • PHPの DateTime::diff() の仕様の不具合?

詳細

  • たとえば以下のそれぞれのケースにおいて期待する結果を示す(人が直感的に思う期待値)
ケース 期待値
5/30 から見た 4/30 1ヶ月前 (1ヶ月0日前 総日数 30日)
1/31 から見た 1/1 1ヶ月前ではない (0ヶ月30日前 総日数 30日)
  • 1月31日の30日前は1月1日だが、5月30日の30日前は4月30日
  • どちらも同じ「30日」の差があるが、基準点によって「1ヶ月前」と表現するかどうかが変わる
  • これを踏まえて、今回のケースを人が直感的に考えると以下になると思う
ケース 期待値
2/1 から見た 3/1 1ヶ月後 (1ヶ月0日後 総日数 28日)
1/31 から見た 3/1 1ヶ月後 (1ヶ月1日後 総日数 29日)
3/1 から見た 1/31 1ヶ月後 (1ヶ月1日前 総日数 29日)

※閏年ではない場合

  • これをPHPはどう解釈しているかというと再現コードの結果から以下であることが分かる
ケース 期待値
2/1 から見た 3/1 1ヶ月後 (1ヶ月0日後 総日数28日)
1/31 から見た 3/1 0ヶ月後 (0ヶ月29日後 総日数29日)
3/1 から見た 1/31 1ヶ月後 (1ヶ月1日後 総日数29日)

※閏年ではない場合

  • 0ヶ月後 (0ヶ月29日後 総日数29日) の算出方法
    • まず単純に2つの日付("1月"と"3月")を引き算して 2ヶ月後 (2ヶ月-30日後) となる code
    • その後平準化が行われる。日数が正しい値になるまでループで処理される。 code
      • このとき 3月が基準となる
      • PHPでは、日数がマイナスだった場合は 基準の前月の日数 でくりさげることを繰り返す code
      • 1回目のループ: 1ヶ月後 (1ヶ月-2日後) (-30に2月の28日を足して-2となる)
      • 2回目のループ: 0ヶ月後 (0ヶ月29日後) (-2に1月の31日を足して29となる)

解決策/対処

  • 現時点でスマートな解決策はなし...?
  • このような直感に反する挙動が起きるのは diffInMonths() の第2引数が「3/1」と「3/2」の場合のみ
  • 現実解としては以下
    • 3/1と3/2におかしくなるので分岐を入れる
      • ちなみに閏年では「3/2」でも正常に動作する
    • 影響を考えた上で軽微なら許容する
    • 比較時の基準点を常に「新しい日付」とする
      • (1/31)->diff(3/1); ではなく (3/1)->diff(1/31);

参考/関連

2. Asia/Tokyo環境下でdiff()の結果がおかしい

問題の挙動

  • 2019/01/01と2019-02-01のdiffは1ヶ月
  • 2019/01/01と2019-03-01のdiffも1ヶ月 ← !?

再現コード

<?php

$testcases = [
    ['2019-01-01', '2019-02-01'],
    ['2019-01-01', '2019-03-01'],
];

foreach ($testcases as [$since, $until]) {
    $tz = new DateTimeZone('Asia/Tokyo');
    $since = new DateTimeImmutable($since, $tz);
    $until = new DateTimeImmutable($until, $tz);

    $diff = $since->diff($until);

    echo sprintf("%sから%sまでのdiffは %s", $since->format('Y-m-d'), $until->format('Y-m-d'), $diff->format('%mヶ月 %d日 総日数 %a日')), PHP_EOL;
}
$ php diff.php
2019-01-01から2019-02-01までのdiffは 1ヶ月 0日 総日数 31日
2019-01-01から2019-03-01までのdiffは 1ヶ月 28日 総日数 59

原因

  • PHPのDateTime::diff()の仕様

詳細

  • PHPのDateTime::diff() は、UTC以外の2つの日付を比較するとき、両方の表記をUTCに修正してからdiffを算出する という仕様になっている code
    • たとえば、Asia/TokyoならUTCに直すと00:00は前日の15:00になる
  • これを踏まえて計算すると
    • 2019-01-01から2019-03-01 は、 2018-12-31から2019-02-28 となる
    • この期間のdiffを取ると 1ヶ月 28日 という結果が得られる

解決策/対処

  • 一度自分でUTCに変換してからdiff()を使うと正しい結果となる
    • 独自のdiff関数を使えばうまくいく
<?php

$testcases = [
    ['2019-01-01', '2019-02-01'],
    ['2019-01-01', '2019-03-01'],
    ['2019-01-01', '2019-04-01'],
];

foreach ($testcases as [$since, $until]) {
    $tz = new DateTimeZone('Asia/Tokyo');
    $since = new DateTimeImmutable($since, $tz);
    $until = new DateTimeImmutable($until, $tz);

    $utc_tz = new DateTimeZone('UTC');
    $since2 = new DateTimeImmutable($since->format('Y-m-d'), $utc_tz);
    $until2 = new DateTimeImmutable($until->format('Y-m-d'), $utc_tz);

    $diff = $since2->diff($until2);

    echo sprintf("%sから%sまでのdiffは %s", $since2->format('Y-m-d'), $until2->format('Y-m-d'), $diff->format('%mヶ月 %d日 総日数 %a日')), PHP_EOL;
}
$ php diff.php
2019-01-01から2019-02-01までのdiffは 1ヶ月 0日 総日数 31日
2019-01-01から2019-03-01までのdiffは 2ヶ月 0日 総日数 59日
2019-01-01から2019-04-01までのdiffは 3ヶ月 0日 総日数 90
  • ただし、サマータイムまで考慮した時・分・秒単位のdiffを出そうとすると上手くいかないので注意
    • 詳しくは↓のqiitaにある。

参考/関連

さいごに

知らないと罠にはまる問題なので完璧に理解せずとも知識として知っておきましょう! ライブラリや言語側でより直感的な結果が返るように改善されていくと良いですね。