発展編! Haskellで「型」のポテンシャルを最大限に引き出すには?【第二言語としてのHaskell】

第二言語としてHaskellを学習するシリーズ。発展編では、実践編で定義した型と関数をモジュールにする方法と、型を見直して関数をさらに安全なものにする方法を紹介します。さらに勉強したい方向けの超発展編付き!

発展編! Haskellで「型」のポテンシャルを最大限に引き出すには?【第二言語としてのHaskell】

こんにちは。Haskell-jpの山本悠滋(igrep)です。
Haskellらしいプログラミングとは何か? について、これまで基本実践を解説してきました。 実践編では、問題にあった型を自分で定義し、 その型を使った関数を定義していくというHaskellプログラミングの流れを、 トランプゲームのブラックジャックの手札の合計計算という例を使って学びました。

この記事ではさらに発展的なHaskellプログラミングの道を示すべく、次の2つの課題に取り組みます。

  1. 定義した型と関数をモジュールにする方法
  2. 型を見直して、関数をさらに安全なものにしていく例の紹介

記事の後半では、Haskellをさらに理解したい人が学ぶべきポイントや、役立つ教材・コミュニティなどを紹介します。

課題1. モジュールを使って、Card型の内部構造を隠蔽する

最初に取り組むのは、関数や型をモジュールにする方法です。 モジュールは、名前空間を分けることで、関数や型を再利用する際に名前が衝突してしまうのを防いだり、複数の関数や型を意味のあるまとまりに分割したりするための機能を提供してくれます。

そのため、モジュールには次のような効果があります。

  • 作成した関数や型を他の人が利用できるようにする
  • 作成した型の内部構造をライブラリーの利用者から隠蔽する

Haskellでより大きなアプリケーションを作るとき、モジュールに関する知識は必要不可欠になるでしょう。

以降では、説明の題材として、実践編で実装したsumHand関数とCard型を使います。

Haskellのモジュールとは

sumHandは、ブラックジャックにおける手札の最適な合計を計算する関数でした。 Card型はトランプのカードを表す型で、sumHandCard型を利用しています。 これらをモジュールにすれば、他の人にも使ってもらえるようになります。

下記のリポジトリーに、あらかじめ筆者がHaskellのモジュールとして作成済みのものがあるので、このソースコードを見ながら解説していきましょう。

1Yuji Yamamoto / haskell-as-second-language-blackjack · GitLab

下記のコマンドを実行して上記のリポジトリーをcloneし、no-haddockという解説用のブランチに切り替えてください。

$ git clone https://gitlab.com/igrep/haskell-as-second-language-blackjack.git
$ cd haskell-as-second-language-blackjack
$ git checkout origin/no-haddock

cloneできたら、src/BlackJack.hsをお使いのテキストエディターで開いてみてください。
下記のようなHaskellのソースコードが冒頭に見えるはずです。 この部分が、Haskellにおけるモジュールの宣言になります。

module BlackJack
  ( Card(A, J, Q, K)

  , cardForTestData
  , deck
  , heartSuit
  , diaSuit
  , cloverSuit
  , spadeSuit
  , sumHand
  ) where

  ...

冒頭のmoduleで始まりwhereで終わる箇所では、このモジュールの名前と、このモジュールがエクスポートする関数や型、値コンストラクターの名前のリストを記載しています。 ここでエクスポートされた関数や型、値コンストラクターなどを、このモジュールのユーザーが実際に利用できるようになります。

上記の例では、それぞれ以下のようになっています。

  • モジュールの名前は、BlackJack
  • エクスポートする名前のリストは、丸カッコで囲った(Card(A, J, Q, K), cardForTestData, deck, heartSuit, diaSuit, cloverSuit, spadeSuit , sumHand)

エクスポートする名前のリストには、実践編で定義したCard型やsumHand関数をはじめとして、さまざまな関数や型が列挙されているのがわかります。

丸カッコ内で改行するとき、カンマを各行の先頭に書いているところにも注目してください。行の末尾にカンマを書いてもよいのですが、Haskellコミュニティーの慣習的に、カンマ区切りのものを改行して列挙するときには行頭にカンマを書くことが多くあります。

型と値コンストラクターのエクスポートと隠蔽

エクスポートする名前のうち、特筆すべき点は、Card(A, J, Q, K)という構文です。 ここで利用しているのは、型とともに「型の値コンストラクター」をエクスポートするための構文です。

型の名前(値コンストラクター1, 値コンストラクター2, ..., 値コンストラクターN)

同じことをしようとして下記のように書いてもエラーになってしまうのでご注意ください。

module BlackJack
  ( Card
  , A
  , J
  , Q
  , K
  -- .. 省略 ..
  )

なお、すべての値コンストラクターをエクスポートしたい場合は、下記のように型の名前(..)と書きます。

module BlackJack
  ( Card(..)
  -- .. 省略 ..
  )

さて、今回書いたモジュールの宣言では、上記のようにCard(..)と書いてCard型の値コンストラクターをすべてエクスポートするのではなく、Card(A, J, Q, K)と書くことで、N以外の値コンストラクターをエクスポートすることにしました。 なぜNをエクスポートしなかったのでしょうか?

それは、このモジュールのユーザー(このモジュールをimportするモジュール)に、あり得ないカードを作らせないためです。

Card型の定義では、2から10のカードを、Nという値コンストラクターに整数Int型の値を紐付けることで表現する、という方法をとりました。 この方法のおかげで、「N2 | N3 | N4 | N5 | N6 | N7 | N8 | N9 | N10」のように数字のカードごとに値コンストラクターを作らずに済んだり、カードが取り得る値への変換を容易に書けたりするといった効果があるのでした。

しかし、この方法には、実践編では触れていなかった重要な問題が1つあります。 Int型の値は、当然、負の数や10より大きい値も取り得るので、たとえばN 3141592など、あり得ない数字のカードが作れてしまうのです!

この問題を緩和するために、上記のモジュール宣言ではNだけをエクスポートしないように設定したのです。 Card(A, J, Q, K)と書くことで、このBlackJackモジュールを使用する側では、値コンストラクターNを直接利用できなくなります。 BlackJackモジュールでエクスポートされている関数や値からしか、値コンストラクターNを使用した値を利用できなくなるのです。

定数の宣言と定義とエクスポート

このBlackJackモジュールでは、値コンストラクターNを直接使用させない代わりに、heartSuitdiaSuitcloverSuitspadeSuitdeckという名前の「Cardのリスト」を利用できるようにしています。 そのリストを定義しているのが以下の部分です。

suit, heartSuit, diaSuit, cloverSuit, spadeSuit :: [Card]
suit = [A] ++ map N [2..10] ++ [J, Q, K]

heartSuit = suit
diaSuit = suit
cloverSuit = suit
spadeSuit = suit

deck :: [Card]
deck =
  heartSuit
    ++ diaSuit
    ++ cloverSuit
    ++ spadeSuit

実践編の復習もかねて、それぞれ簡単に定義を説明しましょう。 上記のHaskellコードでは、suitheartSuitdiaSuitcloverSuitspadeSuitdeckという6つの定数を定義しています。

ただし、このうちheartSuitdiaSuitcloverSuitspadeSuitの4つは、実態としてはsuitと同じ値であるとしています。 これは、ハート、ダイヤ、クローバー、スペードというトランプの4種類のスートごとに異なる名前を定義し分けているものの、今のところトランプを表すCard型にはスートによる区別が一切ないため、いずれも実態としては同じsuitとして扱うことにしているだけです。

まったく同じ値であれば、当然のことながら型もまったく同じものになるので、上記の1行めでは下記のように名前をカンマで区切った記法でまとめて宣言しています。

-- 同じ型の定数であれば、このようにまとめて宣言できる。
suit, heartSuit, diaSuit, cloverSuit, spadeSuit :: [Card]

suitの定義も見てみましょう。 suitの定義では、基本実践で学んださまざまな機能を活用しています。

suit = [A] ++ map N [2..10] ++ [J, Q, K]

まず、map N [2..10]という式では、実践編で学んだmap関数を使用しています。 map関数は、「<関数><リスト>を受け取って、<リスト>の各要素を<関数>の引数として渡して実行し、<関数>の実行結果を新しいリストに入れる」という処理を行う関数でした。 ここでは、<関数>としてNを、<リスト>として[2..10]を渡しています。

関数としてmapに渡しているNは、Card型の値コンストラクターの1つです。 下記のように宣言することで、NInt -> Card、つまり「Int型の値を受け取ってCard型の値を返す」関数になることを思い出してください。 関数なので、当然のごとくNmap関数の引数として渡せます。

data Card =
  A | N Int | J | Q | K

suitの定義に戻りましょう。 mapの2つめの引数である[2..10]は、これまで登場していない構文ですが、 「2から10までの整数のリスト」(つまり[2,3,4,5,6,7,8,9,10]を簡単に作るための構文です。

したがって、map N [2..10]という式により作られるのは、「2から10までの整数に対してNを実行し、2から10までの数字のカードのリスト」ということになります。 1つのsuitに含まれる数字(2~10)のカードをすべて作っているというわけです。

[A]map N [2..10]、それに[J, Q, K]の間でそれぞれ使用している++は、リストを結合する演算子です。 ここでは、エースを表す[A]というリスト、絵柄のカードを表す[J, Q, K]というリスト、map N [2..10]で作った数字のカードのリストを結合して、1つのsuitに含まれるすべてのカードのリストを作っています。

最後のdeckは、トランプのデッキ(ゲームを行うのに必要なすべてのカードをそろえたセット)を表しています。 やはりリストを結合する演算子++を利用して各スートheartSuitdiaSuitcloverSuitspadeSuitを結合し、デッキを作っています。

deck :: [Card]
deck =
  heartSuit
    ++ diaSuit
    ++ cloverSuit
    ++ spadeSuit

モジュールを利用してみよう

説明が長くなりましたが、このように定義されているBlackJackモジュールを実際に利用して試してみましょう。 利用するには、記事の冒頭でgit cloneしてきたリポジトリー(haskell-as-second-language-blackjack)にcdした上で、下記のようにstack buildコマンドを実行してください。

$ stack build

もし次のようなエラーメッセージが表示された場合は、必要なバージョンのGHCがインストールされていません。

No compiler found, expected minor version match with ghc-8.0.2 (x86_64) (based on resolver setting in /home/yu/Downloads/Dropbox/prg/prj/haskell-as-second-language-blackjack/stack.yaml).
To install the correct GHC into /home/yu/.stack/programs/x86_64-linux/, try running "stack setup" or use the "--install-ghc" flag.

その場合は、下記を実行してGHCをインストールしてください。

$ stack setup

stack buildが成功したことを確認できたら、下記のサンプルプログラムをsample.hsという名前のファイルにコピペしてみてください。

import BlackJack

main = print (sumHand [A, J, J])

冒頭でimport BlackJackとすることで、このプログラムでBlackJackモジュールを利用可能にしています。

プログラム自体は、手札が[A, J, J]という3つのカードで構成された場合の合計点数を表示するだけの、他愛のないものです。 それでも、main関数があるので、基本で説明したように実行可能なプログラムになります(ほかのプログラムにimportしてもらうモジュールではありません)

上記のコードをsample.hsとして用意できたら、stack ghcコマンドでコンパイルしてみましょう。

$ stack ghc sample.hs
[1 of 1] Compiling Main             ( sample.hs, sample.o )
Linking sample ...
$

ちゃんとコンパイルできましたね(コンパイルしたコマンドを実行してみてください)

データ型の内部構造を隠蔽する

続いて、sample.hsを次のように書き換えて、BlackJackモジュールでエクスポートされていない値コンストラクターNが利用できないことを確認してみます。

import BlackJack

main = print (sumHand [A, N 9, J])

BlackJackモジュールは値コンストラクターNをエクスポートしていないので、 N 9といった式でBlackJackモジュールがエクスポートしていない値コンストラクターを使用すると、コンパイルエラーになるはずです。 やってみましょう。

$ stack ghc sample.hs
[1 of 1] Compiling Main             ( sample.hs, sample.o )

sample.hs:3:27: error:
    Data constructor not in scope: N :: Integer -> Card

not in scope、すなわち存在しない、というエラーになりました! 意図したとおり、BlackJackモジュールの利用者からは値コンストラクターNを隠せているようですね。

このようにBlackJackモジュールでは、利用者に直接使用されるとトランプのカードとして不適切なカードが作れてしまうNを隠す(エクスポートしない)ことで、より仕様に忠実な値を提供しています。 Haskellのモジュールという仕組みは、単に名前空間を分けるだけでなく、よくあるオブジェクト指向プログラミング言語におけるprivateメソッドのような「データ型の内部構造を隠蔽する」ための仕組みを提供できるのです。

もちろん、ここで紹介した方法が唯一のやり方ではありません。別のやり方でエクスポートする設計もありだと思います。 たとえば、「Card型の値はおろかheartSuitなども一切エクスポートせず、deckのみをエクスポートすることで、Card型の値はdeckに含まれるもののみが利用できるようにする」というモジュールにすることもできるでしょう。 実際にBlackJackモジュールを使ってブラックジャックのゲームを実装していくなら、それで十分なはずです。

いずれにしても、Haskellでは、モジュールがエクスポートする関数・型・値コンストラクターを限定することで、モジュールの利用者に対してモジュールが満たすべき仕様を確実に守らせたり、モジュールの使い方(API)をより明確に示したりすることができます。

課題2. NonEmptyを使って、もっとバグが入りにくい実装を目指す

今度は、sumHand関数やtoPoint関数の定義や型宣言の改良を通して、「型が持っている制約によって、関数に対する要件を必然的に守らせる」というHaskellの醍醐味を体感していただきます。

sumHand関数にはどんな問題があるか?

まず、そもそも現状のsumHand関数にどんな問題が残っているのかをはっきりさせましょう。 sumHand関数を実装した際、「取り得る点数すべてを組み合わせて、組み合わせごとに合計を求める」処理では、下記のようなfoldlを使用した式を組み立てました。

scoreCandidates = foldl plusEach [0] possiblePoints

その際、foldlの第2引数、すなわち<初期値>の部分を空のリストにすると、plusEachの中で呼ばれるconcatMapが空のリストを受け取ってしまい、そのままfoldlも空のリストを返してしまう、という問題がありました。 この問題は、上記の式におけるpossiblePointsの中に仮に1つでも空のリストが混ざっていた場合、同様に発生します。

でも、実際にはそうはなりません。 なぜなら、possiblePointsを作成する際に使ったtoPoint関数が、空のリストを返さないように作られているからです。

possiblePointsそのものが空のリストであった場合、foldl plusEach [0] possiblePoints<初期値>として渡した[0]を返します。possiblePointsは「Intのリストのリスト」になっているため少しややこしいのですが、ここでは「Intのリストのリスト」の要素である「Intのリスト」のうちいずれか、すなわちmap関数に渡したtoPointが空のリストを返した場合を問題にしているのです。ご注意ください。

確認のために、改めてtoPoint関数の定義を見直してみましょう。

toPoint :: Card -> [Int] -- 関数の型の宣言
toPoint A = [1, 11]      -- Aの場合は1と11を取り得る
toPoint (N n) = [n]      -- 通常の数字のカードであれば、カードに書かれた数字のみを取り得る
toPoint _ = [10]         -- そのほかの場合は10のみを取る

確かに、この定義を見れば、toPoint関数は「いかなる引数を受け取っても空のリストを返すことがない」ことがわかりますね。

しかしながら、このことは、toPoint関数の定義を実際に見に行かなければわかりません。

toPoint関数程度の短い単純な関数であれば、実際に実装を読むのもたいした手間ではないでしょう。 しかし、もっと複雑な関数、それも、あなたではない人が書いた関数について、はたして本当に空リストを返さないかどうかを見極めるのは困難になるでしょう。 これが、現状のtoPoint関数に残されている大きな問題です。

では、このような問題をどのような手段で解消すればいいでしょうか? Haskellでは、この問題にも「型」を利用した解決方法があります。

もしあなたがtoPoint関数(あるいは、もっと複雑な関数)を誰か別の人に書いてもらうとして、その人にtoPoint関数の振る舞いを簡単に説明し、 型宣言を教えるだけで「toPoint関数が返すリストが空でない」ことまで保証できるなら、もっと安心してtoPoint関数以外の部分の実装に取り組めるでしょう。

そこでここでは、文字通り空でないリストを提供してくれるData.List.NonEmptyというモジュールを紹介します。

Data.List.NonEmptyモジュールに入っているNonEmptyという型は、「先頭の要素」と「残りの(空かも知れない、普通の)リスト」をそれぞれプロパティとして持つことによって、必ず要素が1つ以上あるリストとなることを保証してくれます。 このNonEmptyを利用してsumHand関数とtoPoint関数、それにplusEach関数を書き換えていくことで、「型が持っている制約によって、関数に対する要件を必然的に守らせる」ことを体験してみましょう!

2

NonEmptyを使う

NonEmptyを使用する準備

さっそく、Data.List.NonEmptyモジュールからNonEmpty型をimportしましょう。 :|という演算子も合わせてインポートします。

import Data.List.NonEmpty (NonEmpty((:|)))

上記のimport文をコピーして、blackjack.hs先頭に貼り付けてください。 それができたら、stack ghciコマンドを起動し、blackjack.hsを読み込んで、NonEmpty型が使用できるようになったことを:i NonEmptyで確認しましょう。

$ stack ghci
> :l blackjack.hs
> :i NonEmpty
data NonEmpty a = a :| [a]      -- Defined in ‘Data.List.NonEmpty’
instance Eq a => Eq (NonEmpty a) -- Defined in ‘Data.List.NonEmpty’
instance Monad NonEmpty -- Defined in ‘Data.List.NonEmpty’
instance Functor NonEmpty -- Defined in ‘Data.List.NonEmpty’
instance Ord a => Ord (NonEmpty a)
  -- Defined in ‘Data.List.NonEmpty’
instance Read a => Read (NonEmpty a)
  -- Defined in ‘Data.List.NonEmpty’
instance Show a => Show (NonEmpty a)
  -- Defined in ‘Data.List.NonEmpty’
instance Applicative NonEmpty -- Defined in ‘Data.List.NonEmpty’
instance Foldable NonEmpty -- Defined in ‘Data.List.NonEmpty’
instance Traversable NonEmpty -- Defined in ‘Data.List.NonEmpty’

上記のようにNonEmpty型の情報が出力されるはずです。 :i NonEmptyを実行した直後に出力されるdata NonEmpty a = a :| [a]という部分が、NonEmpty型の定義です。

ここまでに登場した型とは違う、ちょっと見慣れない定義ですね。詳しく解説しましょう。 あらかじめ注記しておきますが、NonEmptyの定義はHaskellにおけるほかの型の定義と比べてけっこう変わっています。

NonEmptyの定義

NonEmpty型は次のような定義になっています。

data NonEmpty a = a :| [a]

data NonEmptyで「NonEmptyという型の名前を宣言」しているのは、他の型と同じです。 しかし今回は、その後にすぐイコール記号がくるのではなく、小文字のaが続いています。

このaは、filter関数の型宣言で出てきた型変数です。 正確には、NonEmpty型が受け取る型引数の宣言です。 NonEmptyには、リストと同じように任意の型の要素を格納できなければならないので、「要素の型」として型引数を受け取るものとして定義されています。

続いて、イコールより後ろ、すなわちNonEmpty型の定義の本体に当たる部分です。ここには、Card型を定義したときと同様、NonEmpty型の値コンストラクターとその引数が列挙されているはずです。 でも、a :| [a]という構文のどこをどう読めば、値コンストラクターと引数がわかるのでしょうか……?

実は、a :| [a]の真ん中にある「:|」という、なんだか英語圏の顔文字みたいな記号が、NonEmpty型の値コンストラクターの名前です。 そして、この値コンストラクターは、左右にあるaおよび[a]という(型の値の)2つの引数を取ります。

これだけでは話が見えないと思うので、詳しく説明しましょう。 Haskellは、いろいろな記号を二項演算子として定義できるという、変わった特徴を備えています。しかも、値コンストラクターさえ、記号を使って二項演算子として定義できます。 ただし、値コンストラクターを記号で定義する場合には、必ず名前をコロン:で始めなければなりません。 実際のところ、たまにしか使われない機能なのですが、それがNonEmpty型の定義では使用されているのです。

つまり、演算子:|は、左辺にa型の値、右辺に[a]型の値aのリスト)を受け取ることで、NonEmpty型の値を作り出します。 先ほど、NonEmptyという型は「先頭の要素」と「残りの(空かも知れない普通の)リスト」をそれぞれプロパティとして持つと述べたとおり、値コンストラクター:|は引数として「a型の値」を1つと「そのリスト」を受け取るのです。

実際にNonEmpty型の値をいくつか作って試してみましょう。

-- 真偽値の空ではないリスト
> True :| [True, False]
True :| [True,False]
> :t True :| [True, False]
True :| [True, False] :: NonEmpty Bool

-- Num型クラスを実装した何らかの型aの値の、空ではないリスト
> 1 :| [1,2,3]
1 :| [1,2,3]
> :t 1 :| [1,2,3]
1 :| [1,2,3] :: Num a => NonEmpty a

-- 文字型の空ではないリスト。
-- 文字列は文字のリストなので「空ではない文字列」とも言える。
> 'a' :| "abc"
'a' :| "abc"
> :t 'a' :| "abc"
'a' :| "abc" :: NonEmpty Char

-- 真偽値のリストの空ではないリスト
-- 「空ではないリスト」の各要素は空になり得る点に注意
> [] :| [[True]]
[] :| [[True]]
> :t [] :| [[True]]
[] :| [[True]] :: NonEmpty [Bool]

ちゃんとNonEmpty型の値が作れましたね。

NonEmpty型の値を操作する関数

続いて、これから使うNonEmpty型の値を操作するための便利な関数をimportします。 先ほどblackjack.hsに追記したimport文に、さらに次のimport文を書き加えてください。

import qualified Data.List.NonEmpty as NonEmpty
import Data.Semigroup (sconcat)

上記のうち1つめのimport qualified Data.List.NonEmpty as NonEmptyという行では、Data.List.NonEmptyモジュールをNonEmptyという修飾語で修飾付きインポート(qualifiedインポート)しています。 このようにqualifiedで「修飾付きインポート」をすると、そのモジュールが提供している任意の型や関数を「モジュール名.名前」というふうにピリオド付きの表記で参照できるようになります。

したがって、以降ではData.List.NonEmptyの任意の型や関数がNonEmpty.<名前>として参照できるようになります。 Data.List.NonEmptyモジュールでは、NonEmpty型の値を扱う関数として、mapfilterといった標準のリストに対する関数と同じ名前のものを数多くエクスポートしています。 NonEmpty型に対する関数と、標準のリストに対する関数とをプログラムの中で区別できるようにするために、ここでは修飾付きimportを使用しました。

2つめのimport Data.Semigroup (sconcat)では、Data.Semigroupモジュールが提供してくれるsconcatという関数をimportしています。 Data.Semigroupモジュールは、NonEmpty型とよく似たさまざまなデータ構造に対する便利な関数や型クラスを提供してくれます。

今回は、Data.List.NonEmptyimportするだけでは使えないsconcatという便利な関数が必要だったのでimportしました。 どんな関数かは後のお楽しみに。

上記の2つのimport文を追加できたら、また:l blackjack.hsして読み込んで試してみましょう。

> :l blackjack.hs
> :t NonEmpty.map
NonEmpty.map :: (a -> b) -> NonEmpty a -> NonEmpty b
> NonEmpty.map not (True :| [False])
False :| [True]
> :t sconcat
sconcat :: Data.Semigroup.Semigroup a => NonEmpty a -> a

上記のようにNonEmpty.map関数やsconcat関数などが利用できるようになっていればOKです!

toPoint関数を書き換える

それでは、いよいよNonEmpty型を使ってsumHand関数やtoPoint関数を改良してみましょう。 まずはtoPoint関数です。

toPoint :: Card -> [Int] -- 関数の型の宣言
toPoint A = [1, 11]      -- Aの場合は1と11を取り得る
toPoint (N n) = [n]      -- 通常の数字のカードであれば、カードに書かれた数字のみを取り得る
toPoint _ = [10]         -- そのほかの場合は10のみを取る

ここでNonEmpty型を使う動機を思い出しておきましょう。 「toPoint関数が必ず空でないリストを返すことを、NonEmpty型に書き換えることにより、型レベルで保証したい」というのが動機です。 そこで、まずは型宣言から書き換えてみます。

toPoint :: Card -> NonEmpty Int -- 関数の型の宣言
toPoint A = [1, 11]      -- Aの場合は1と11を取り得る
toPoint (N n) = [n]      -- 通常の数字のカードであれば、カードに書かれた数字のみを取り得る
toPoint _ = [10]         -- そのほかの場合は10のみを取る
NonEmpty型の値を返すように関数の本体を書き換える
エンジニアHubに会員登録すると
続きをお読みいただけます(無料)。
登録のメリット
  • すべての過去記事を読める
  • 過去のウェビナー動画を
    視聴できる
  • 企業やエージェントから
    スカウトが届く