1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに: Maybeを過小評価していた

Haskellで競技プログラミングをやっているとData.Maybe(以降Maybeと記載)を扱うことがある。

自分は積極的にMaybeを使うことは行っておらず、どちらかというとチェック例外のようにコンパイルエラーがでるからJustNothingの処理をしている状態であったが、最近Maybeを使うことでコードが読みやすくなることに気がついた。

そのため、この記事では以前の自分のように雰囲気でMaybeを使っている人向けに、その良さを少しでも伝えることを目的としている。

環境

サンプルとして記載しているコードは以下の環境で動作検証を行っている

  • 2026年6月2日時点でのAtCoder環境
  • 自分の手元の環境
    • OS: Ubuntu 24.04 LTS
    • GHC: 9.6.7
    • OpenJDK: 21.0.10 2026-01-20

Javaでは存在しない値をどう扱うか

HaskellのMaybeについて語る前に比較対象として、Javaでの存在しない値の扱いについて書いておく。

nullを使う

JLSのnullに関する記述を引用する。

3.10.8 The Null Literal

The null type has one value, the null reference, represented by the null literal null, which is formed from ASCII characters.
NullLiteral:
null

A null literal is always of the null type (§4.1). 1

また、null型そのものについては次のようにも述べられている。

There is also a special null type, the type of the expression null (§3.10.8, §15.8.1), which has no name.

Because the null type has no name, it is impossible to declare a variable of the null type or to cast to the null type.

The null reference is the only possible value of an expression of null type. 2

上記を要約すると、以下のようになる。

  • null型とはただ一つ、null参照(=nullリテラル)だけを値空間に持つ、名前を持たない特別な型である

  • null型には名前がないため、null型の変数を宣言したり、null型へのキャストはできない

    public class NullTypeDemo {
        public static void main(String[] args) {
            // null x1 = null; // null型の変数は作れない error: not a statement
            // Object x2 = (null) "hello"; // null型へのキャストはできない error: ';' expected
    
            // nullリテラルは任意の型の変数に代入できる。
            String s = null;
            // null型からのキャスト(null参照を他の参照型へキャスト)は可能
            String s2 = (String) null;
            System.out.println(s2); // nullと出力される
        }
    }
    

JavaのnullにはNullPointerExceptionまわりにつらい部分が詰まっている。

nullつらいポイント①: コンパイル時にnullチェックができない

前述した通り、nullリテラルは任意の型に代入することができるが、nullに対してメソッドを呼び出してもコンパイル時に検知できず、実行時にNullPointerExceptionが上がる。

NullPo.java
public class NullPo {
    public static void main(String[] args) {
        String word = null;
        System.out.println(word.length()); // nullに対してメソッド呼び出し -> NullPointerException
    }
}
javac NullPo.java # コンパイルエラーなし
java NullPo
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "<local1>" is null
        at NullPo.main(NullPo.java:4)

nullつらいポイント②: どこからnullが入ったのかわかりにくい

nullリテラルがどこで混入されたのかもわかりにくいという問題点もある。

以下の簡易的なサンプルコードの場合、getWord()メソッド経由でnullが入ったのか、直接wordnullが代入されたのかがエラーメッセージからわからない。

NullPo.java
// getWordからnullが返された例
public class NullPo {
    public static void main(String[] args) {
        // String word = null;
        String word = getWord();
        System.out.println(word.length()); // nullに対してメソッド呼び出し -> NullPointerException
    }
    public static String getWord() {
      return null; // メソッドが失敗してnullが返されたとイメージしてほしい
    }
}
NullPo.java
// nullで値が上書きされた例
public class NullPo {
    public static void main(String[] args) {
        // String word = getWord();
        String word = null;
        System.out.println(word.length()); // nullに対してメソッド呼び出し -> NullPointerException
    }
    public static String getWord() {
      return null; // メソッドが失敗してnullが返されたとイメージしてほしい
    }
}
# 実行時エラーは同じ
java NullPo.java
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "<local1>" is null
        at NullPo.main(NullPo.java:5)

Optionalを使って多少nullのつらい部分を解決する

このように、nullの抱える問題を解決するため、JavaにはOptionalが存在する。

A container object which may or may not contain a non-null value. If a value is present, isPresent() returns true. If no value is present, the object is considered empty and isPresent() returns false. 3

Optionalを使うことで、プログラマはその値が存在しないかもしれないことを知ることができる。

以下の例はnullを返すレガシーAPIをOptionalでラップした例だ。

import java.util.Optional;

public class Opt {
    // nullを返すレガシーAPIをそのまま使いたくない!
    static String legacyFind(String id) {
        return id.equals("1") ? "Alice" : null;
    }
    // Optionalでラップ
    static Optional<String> find(String id) {
        return Optional.ofNullable(legacyFind(id));
    }
    public static void main(String[] args) {
        // String user1 = find("1"); // これはコンパイルエラー incompatible types
        Optional<String> user1 = find("1"); // Optional[Alice]
        System.out.println(user1.get()); // Alice
    }
}

しかし、Optionalにもいくつか抜け穴が存在する。

まずは、Optional自体にnullを再代入することが可能である点だ。
この場合も、コンパイルエラーでは検知できず、実行時にNullPointerExceptionが発生してしまう。

import java.util.Optional;

public class Opt {
    // nullを返すレガシーAPIをそのまま使いたくない!
    static String legacyFind(String id) {
        return id.equals("1") ? "Alice" : null;
    }
    // Optionalでラップ
    static Optional<String> find(String id) {
        return Optional.ofNullable(legacyFind(id));
    }
    public static void main(String[] args) {
        Optional<String> user1 = find("1"); // Optional[Alice]
        user1 = null; // nullが代入できてしまう
        System.out.println(user1.get()); // 実行時にNullPointerExceptionが発生する
    }
}

これを防ぐためには、なるべく変数は再代入不可のfinalで宣言するくらいしかなく、根本的な解決はない。

final Optional<String> user1 = find("1");

次にOptional.get()は中身が空でないことの検証をコンパイラが強制しないことだ。

以下のコードを実行すると、実行時エラーNoSuchElementExceptionが発生する。

import java.util.Optional;

public class Opt {
    // nullを返すレガシーAPIをそのまま使いたくない!
    static String legacyFind(String id) {
        return id.equals("1") ? "Alice" : null;
    }
    // Optionalでラップ
    static Optional<String> find(String id) {
        return Optional.ofNullable(legacyFind(id));
    }
    public static void main(String[] args) {
        Optional<String> user9 = find("9"); // Optional.empty
        // 値がemptyかどうかのチェックをコンパイラに強制してほしいが強制されない
        System.out.println(user9.get());
    }
}

以下のように、isPresent()を使えば実行時に値の存在をチェックすることはできるが、チェックはコンパイラによって強制されていないため、プログラマの努力に頼った解決策となってしまう。

import java.util.Optional;

public class Opt {
    // nullを返すレガシーAPIをそのまま使いたくない!
    static String legacyFind(String id) {
        return id.equals("1") ? "Alice" : null;
    }
    // Optionalでラップ
    static Optional<String> find(String id) {
        return Optional.ofNullable(legacyFind(id));
    }

    public static void main(String[] args) {
        Optional<String> user9 = find("9"); // Optional.empty
        // 値の存在をチェックするように改善
        if (user9.isPresent()) {
            System.out.println(user9.get()); // user9は空なので、このブロックは実行されない
        }
    }
}


Haskellの場合

undefined

この記事のタイトルにもあるMaybeについて語る前に、Javaのnullに近い概念としてundefinedを紹介しておく。

undefinedは、どんな型にもなれる多相型(polymorphic type)の値であり、評価されたときに例外を投げる4

undefined :: HasCallStack => a 

undefinedは、あらゆる型になれるという点でnull同様、あらゆる場所に混入可能であり、実行時エラーの原因となりうる。

しかし、nullよりはマシな存在と言える。

まず、Haskellは遅延評価を採用しているため、undefinedがコード中に存在していても、評価されるまでは例外が上がらない。

また、undefinedが表す状態は未定義の1つだけであるという点もnullと異なる点である。

自分はいきなり、メインの操作を書くのが難しいときにwhere句などを使って部品を作る前にメインの操作をundefinedにいったんすることでLinterの警告を抑止するのに使うことが多い5
それ以外の場合には、後述するMaybeを使っている。

solve :: [Int] -> Int
solve xs = undefined
  where
    tmp = "何かしらの途中操作"

↓Linterの警告が抑止できる

image.png

undefinedで埋めてないとLinterが警告を出す

image.png

Maybe

登場までかなり長かったが、いよいよMaybeについて紹介する。

The Maybe type encapsulates an optional value. A value of type Maybe a either contains a value of type a (represented as Just a), or it is empty (represented as Nothing). Using Maybe is a good way to deal with errors or exceptional cases without resorting to drastic measures such as error.

The Maybe type is also a monad. It is a simple kind of error monad, where all errors are represented by Nothing. A richer error monad can be built using the Either type.6

Maybe a型の値は、値がある場合にはJust aを、値がない場合にはNothingで表される。
これを使うことで、例外的なケースをうまく処理できるようになる。

MaybenullOptionalと異なり、コンパイラが値がない可能性の検証を強制する78

findは、関数がTrueを返す値が見つかればJust aを、見つからなければNothingを返す関数である。

以下は値の検証をしない例で、コンパイルエラーになる。

import Data.List (find)

main :: IO ()
main = do
  let xs = ['a'..'z']
  let c = find (=='h') xs
  print (c : "askell") -- 検証なし
error: [GHC-83865]
    • Couldn't match type ‘Char’ with ‘Maybe Char’
      Expected: [Maybe Char]
        Actual: String

値をきちんと検証する例はこちら。

import Data.List (find)

main :: IO ()
main = do
  let xs = ['a'..'z']
  let c = find (=='h') xs
  -- print (c : "askell")
  case c of
    Just a -> print (a : "askell")
    Nothing -> print "Nothing"

Rustをご存知の方は、RustのOption型の大先輩がHaskellのMaybeと思えば良い。


実装例: Maybeへの書き換えで見通しがよくなる例

以下の問題の解答を実装したとき、Maybeを使うとロジックがきれいになることに気がついた。

Maybeを使わない例

この問題では、条件を満たす生徒がいない場合、-1を出力することになっている。
そのため、foldl'の初期値を-1にして、生徒が見つかったら更新を行うという形で実装している。

{-# OPTIONS_GHC -Wunused-imports #-}

import Data.List (foldl')

solve :: Int -> Int -> [Int] -> Int
solve l r ps = fst $ foldl' go (-1, l - 1) (zip [1 ..] ps :: [(Int, Int)])
  where
    go :: (Int, Int) -> (Int, Int) -> (Int, Int)
    go acc@(_, p) acc'@(_, p')
      | p' > p && p' <= r = acc' -- 同じ点数の場合は出席番号が若いほうが優先される
      | otherwise = acc

main :: IO ()
main =
  interact $ \inputs ->
    let ls = lines inputs
        [n, l, r] = map read . words $ head ls :: [Int]
        ps = map read . words $ ls !! 1 :: [Int]
     in show (solve l r ps) ++ "\n"

Maybeを使う例

先ほどのMaybeを使わない実装はシンプルでそれはそれでありだと思うが、コードを読む人の視点に立ったときに、問題文を読まないとfoldl'の初期値が-1になっているのがなぜか読み取りづらい。

Maybeを使うことで、若干記述量は増えるが、-1が例外時の出力であることがコードから読み取れるようになる。

{-# OPTIONS_GHC -Wunused-imports #-}

import Data.List (foldl')

-- Maybeを使うことで単純にデータの変換になってうれしい。
solve :: Int -> Int -> [Int] -> Int
solve l r ps =
  case foldl' go Nothing (zip [1 ..] ps :: [(Int, Int)]) of
    Nothing -> -1
    Just (i, _) -> i
  where
    go :: Maybe (Int, Int) -> (Int, Int) -> Maybe (Int, Int)
    go acc (i', p')
      | p' < l || r < p' = acc -- 範囲外は無視
      | otherwise = case acc of
          Nothing -> Just (i', p')
          Just (_, p)
            | p' > p -> Just (i', p') -- 真に大きいときだけ更新 → 同点は最小番号が残る
            | otherwise -> acc

main :: IO ()
main =
  interact $ \inputs ->
    let ls = lines inputs
        [n, l, r] = map read . words $ head ls :: [Int]
        ps = map read . words $ ls !! 1 :: [Int]
     in show (solve l r ps) ++ "\n"


Eitherについて

自分のHaskellで競技プログラミング以外ほとんどやっていないので、Eitherを使いたい場面に遭遇していないため、割愛。使いだしたらそのうち記事にする予定。


まとめ

  • Javaのnullの弱点を補うためにOptionalが存在する。しかし、Optionalは値の存在チェックをコンパイラによって強制することはできない。
  • HaskellのMaybeを使って存在しない値を扱う。Maybeはコンパイラで値の存在チェックを強制する。
  • Maybeを使うことで例外的状態を扱える。

おまけ: Maybeを使った実装例を追加で掲載

{-# LANGUAGE CPP #-}
{-# OPTIONS_GHC -Wno-x-partial #-}
{-# OPTIONS_GHC -Wunused-imports #-}

import Data.List (find, permutations)

-- (1..N)の全順列から、条件を満たすものを最初に1つ見つける。
-- 条件: 各iについて Ai == -1 または Pi == Ai。
solve :: Int -> [Int] -> [String]
solve n as = case find valid (permutations [1 .. n]) of
  Nothing -> ["No"]
  Just p -> ["Yes", unwords (map show p)]
  where
    valid :: [Int] -> Bool
    valid p =
      and
        [ a == -1 || p_i == a
        | (p_i, a) <- zip p as
        ] -- andで全部Trueの時だけTrueを返す

main :: IO ()
main =
  interact $ \inputs ->
    let ls = lines inputs
        n = read (head ls) :: Int
        as = map read . words $ ls !! 1 :: [Int]
     in unlines (solve n as)


Reference

  1. https://docs.oracle.com/javase/specs/jls/se25/html/jls-3.html#jls-3.10.8

  2. https://docs.oracle.com/javase/specs/jls/se25/html/jls-4.html#jls-4.1

  3. https://download.java.net/java/early_access/loom/docs/api/java.base/java/util/Optional.html

  4. https://hackage-content.haskell.org/package/base-4.22.0.0/docs/Prelude.html#v:undefined

  5. https://wiki.haskell.org/Undefined

  6. https://hackage-content.haskell.org/package/base-4.22.0.0/docs/Data-Maybe.html

  7. -Wincomplete-patterns(-Wall)依存で、デフォルトでは Justだけ書いても通る。

  8. fromJustを使うとNothingの場合に実行時例外が発生する。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?