はじめに: Maybeを過小評価していた
Haskellで競技プログラミングをやっているとData.Maybe(以降Maybeと記載)を扱うことがある。
自分は積極的にMaybeを使うことは行っておらず、どちらかというとチェック例外のようにコンパイルエラーがでるからJustとNothingの処理をしている状態であったが、最近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:
nullA 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が上がる。
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が入ったのか、直接wordにnullが代入されたのかがエラーメッセージからわからない。
// 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が返されたとイメージしてほしい
}
}
// 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の警告が抑止できる
↓undefinedで埋めてないとLinterが警告を出す
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で表される。
これを使うことで、例外的なケースをうまく処理できるようになる。
MaybeはnullやOptionalと異なり、コンパイラが値がない可能性の検証を強制する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
-
https://docs.oracle.com/javase/specs/jls/se25/html/jls-3.html#jls-3.10.8 ↩
-
https://docs.oracle.com/javase/specs/jls/se25/html/jls-4.html#jls-4.1 ↩
-
https://download.java.net/java/early_access/loom/docs/api/java.base/java/util/Optional.html ↩
-
https://hackage-content.haskell.org/package/base-4.22.0.0/docs/Prelude.html#v:undefined ↩
-
https://hackage-content.haskell.org/package/base-4.22.0.0/docs/Data-Maybe.html ↩
-
-Wincomplete-patterns(-Wall)依存で、デフォルトでは Justだけ書いても通る。 ↩

