【git】開発とレビューのバランスを求めて 〜ブランチ分割編〜

開発時、どの粒度でcommitするか考えながらコードを書きたくない。
特に、開発中は試行錯誤しながら実装を進めているので、最初から設計しきれていない場合も多い。怠慢と言われてしまうと弱いが、開発スピードも考慮すると、試行錯誤するのが早い。
確かにそんな状態で開発を進めていると、commit履歴が汚れてしまうのも現実だ。このままレビュワーにレビュー依頼をすると、レビュワーの側の負担が大きくなってしまう1

要件の整理

以下ができると、あとはチームで運用ルールを定めればよさそうだ。

  • 後で、ブランチを分割したい
  • 後で、commitをまとめたい

  • (ついでに)masterが進んでしまった場合に、新しいHEADからbranchを分けたい2

これらを実現するため、試してみたやり方をメモとしてまとめる。

本記事では、「ブランチを分割したい」を検証する。

「commitをまとめたい」は以下の記事で別にした。

https://cipepser.hatenablog.com/entry/git-operation-merge-commitcipepser.hatenablog.com

前提

  • conflictするパターンは考えない3

やりたいこと

commitが積み重なり、変更が大きくなったブランチを考える。レビュワーの負担減を目的に、このブランチを分割したい。

やり方

git cherry-pickを使う

(準備)commit用のファイルを用意する

ls
README.md a.txt     b.txt

txtファイルの中身はファイル名が一文字書いてある。 例えばa.txtならaとだけ書いてある。適当に用意しただけなので、意味はない。

❯ cat a.txt
a

後続に、git diffしている箇所があるので、git logを補足(これが初期状態)。

❯ git log --pretty=oneline
635c5d5570a6c0492be7a924dda3df314bfd13b8 (HEAD -> master) first commit

大きくなりすぎたブランチを作る

大きくなりすぎた(a.txtとb.txtを同時に追加した)ブランチをadd-a-and-bブランチとする。

❯ git checkout -b add-a-and-b

a.txtを追加したcommitと、b.txtを追加したcommitを行う。
(「2つもcommitあるなんて大きすぎるよ」の状態)

❯ git add a.txt 
❯ git commit -m "add a.txt"

❯ git add b.txt
❯ git commit -m "add b.txt"

git cherry-pickでcommit idを使うので、git logを確認しておく。

❯ git log --pretty=oneline
451fcd8e75608ca6364fd2ad9f9699adf8a8b3fb (HEAD -> add-a-and-b) add b.txt
41295c0d3043f560a61f57a0f44d4785056b4269 add a.txt
635c5d5570a6c0492be7a924dda3df314bfd13b8 (master) first commit

(本題)ブランチを分割する

a.txtを追加するadd-aブランチと、b.txtを追加するadd-bブランチに分ける

add-aブランチ

masterブランチからadd-aブランチを作成する。

❯ git checkout master
❯ git checkout -b add-a

add a.txtのcommitをpickする。

# `first commit`から`add a.txt`のcommit idを指定
❯ git cherry-pick 635c5d5570a6c0492be7a924dda3df314bfd13b8..41295c0d3043f560a61f57a0f44d4785056b4269

結果(add-aのcommitだけpickできた)

❯ git log --pretty=oneline
b1abe76c7f481cecf64934a98dcd1971a13496e6 (HEAD -> add-a) add a.txt
635c5d5570a6c0492be7a924dda3df314bfd13b8 (master) first commit

add-bブランチ

masterブランチからadd-bブランチを作成する。

❯ git checkout master
❯ git checkout -b add-b
Switched to a new branch 'add-b'

add b.txtのcommitをpickする。

# `add b.txt`のcommi idを指定
❯ git cherry-pick 451fcd8e75608ca6364fd2ad9f9699adf8a8b3fb

結果(add-bのcommitだけpickできた)

❯ git log --pretty=oneline
bceca8ee93f256fb1d4083798c1aed1e3b1c8c16 (HEAD -> add-b) add b.txt
635c5d5570a6c0492be7a924dda3df314bfd13b8 (master) first commit

masterにマージする4

add-aブランチのマージ

masterブランチに戻る。

❯ git checkout master

masterブランチにadd-aブランチをマージする。実運用ではGitHub上でプルリクを送ることになると思う。

❯ git merge add-a
Updating 635c5d5..b1abe76
Fast-forward
a.txt | 1 +
1 file changed, 1 insertion(+)
create mode 100644 a.txt

結果

❯ git log --pretty=oneline
b1abe76c7f481cecf64934a98dcd1971a13496e6 (HEAD -> master, add-a) add a.txt
635c5d5570a6c0492be7a924dda3df314bfd13b8 first commit

add-bブランチのマージ

こちらもgit mergeでマージする5

念のため、事前状態を記載しておく。

❯ git checkout add-b
Switched to branch 'add-b'

❯ git log --pretty=oneline
bceca8ee93f256fb1d4083798c1aed1e3b1c8c16 (HEAD -> add-b) add b.txt
635c5d5570a6c0492be7a924dda3df314bfd13b8 first commit

マージする。

❯ git checkout master
❯ git merge add-b
Merge made by the 'recursive' strategy.
b.txt | 1 +
1 file changed, 1 insertion(+)
create mode 100644 b.txt

結果(add-aブランチとadd-bブランチを分割できている)

❯ git log --pretty=oneline
298b644f77788e7f2db45f21bfc56731fc46470d (HEAD -> master) Merge branch 'add-b'
bceca8ee93f256fb1d4083798c1aed1e3b1c8c16 (add-b) add b.txt
b1abe76c7f481cecf64934a98dcd1971a13496e6 (add-a) add a.txt
635c5d5570a6c0492be7a924dda3df314bfd13b8 first commit

「commitをまとめたい」については、以下に書いた。

https://cipepser.hatenablog.com/entry/git-operation-merge-commitcipepser.hatenablog.com


  1. 打ち消しcommitだったり、そもそもが意味のないcommitだったりも含まれてしまうことも…

  2. 自分でブランチを分割したときにも発生するので、実は対応が必須

  3. 今、考えたいこと以上に複雑な問題にしない)

  4. 実際には、マージ前にadd a.txtのレビューを行っているはず。

  5. add-aブランチをmasterにマージしたことで、add-bブランチから見るとmasterが先に進んでしまっている。git rebase masterだと、一番最初にbranchを分けた時点からのdiffになってしまい、レビュワーの負担が減らなかったため、git mergeを使っている。conflictするパターンも含めて深堀りが必要か。あくまで本記事の対象は、「レビュー負担軽減を目指し、conflictがない範囲でブランチ分割を行う」こととした。

2019年読んで良かった本、記事

2019年に読んだ本と読んで良かった記事のリスト。自分用。
リストは、必ずしも今年に発表されたものではなく、自分が読んだタイミングが今年。 以前は年度ごとに振り返っていたが、ここ2年ほどやらなくなってしまったので再開(年末のほうが他の方に触発されてモチベ維持できそう)。

また、各本の感想を書くと、頑張り過ぎなので今回からやめる。

その分、読んでよかった記事を増やした。 記事を一覧化すると、自分がどんなことに興味があったのか可視化できてよかった。 何年か継続できたら振り返りに使いたい。

技術書

全体的に豊作だった。 特にWriting A Compiler In Go, Thorsten Ballは素晴らしい。 書評は以下に書いた。

cipepser.hatenablog.com

また、初めてHaskellに触れた。 読めるコードが増えた。 他にも、モナドのような「聞いたことはあるけどよく知らない概念」を手に入れられたのが収穫。

ビジネス書

本は目標ほど数を読めなかった。 その分、d10n Labのレポートを昨年分と今年分で350本ほど読んだ。

記事

概ね読んだ順番でリスト化する。
結構忘れてしまったものも多い。少なくとも当時の自分が気に入ったのだから、もう一度読み直してもいい。

Pocketでお気に入りしたものからリスト化した。体感、50記事に1記事くらいお気に入りする。 ちょっと記事に時間取られすぎてしまったのは反省点。来年は、論文と本を読む時間を多くしたい。

ざっと眺めてみると今年から読み始めたジャンルは、DeFi、金融、法。 変わらずネットワークやGo、Rustも読んでいるので、単純にジャンルの増加が読む記事の増加に直結。 体系的な知識に落とし込んで、読まない記事を増やすべき。

Github

後半が少ない。これも記事消化を優先してしまったため。 来年は言い訳せずに手を動かす。

f:id:cipepser:20191225235520p:plain

【Rust】mapとand_thenの違い

この記事は Rust Advent Calendar 2019 の9日目の記事です。

最近、すごいHaskellを読み終わりました。

Rustに戻ってきたので、改めてmapand_thenの違いを整理したいと思います。モナドで一般化されますが、本記事ではOption型を例に考えます。

環境

❯ rustup --version
rustup 1.20.2 (13979c968 2019-10-16)

❯ cargo version
cargo 1.38.0 (23ef9a4ef 2019-08-20)

実装の確認

まずはmapand_thenがどのように実装されているか見てみましょう。以下は、libcore/option.rsの実装です。

pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> Option<U> {
    match self {
        Some(x) => Some(f(x)),
        None => None,
    }
}

pub fn and_then<U, F: FnOnce(T) -> Option<U>>(self, f: F) -> Option<U> {
    match self {
        Some(x) => f(x),
        None => None,
    }
}

クロージャFシグネチャに注目します。
FnOnceを無視すると以下のようになっています。

  • map: T -> U
  • and_then: T -> Option<U>

例として、値を2倍にするようなクロージャを考えると、以下のような感じでしょうか。 mapは値をそのまま返し、and_thenSomeで包んで返します。

fn main() {
    let m = Some(1).map(|x| x * 2);
    println!("{:?}", m); // Some(2)

    let a = Some(1).and_then(|x| Some(x * 2));
    println!("{:?}", a); // Some(2)
}

上記の例のように単純に値を写像させたいだけの場合は、mapが便利なのですが、複数の(Optionを返す)関数をチェインさせたい場合は、and_thenが真価を発揮します。1

複数の関数をチェインさせる例として、以下のようなDBに対して、idからnameを引き、nameからpriceを得ることを考えます。

PRODUCTSテーブル

id name
0 desk
1 chair
2 whiteboard
3 pencil

PRICEテーブル

name price
desk 300
chair 100
whiteboard 20

実装

DBの代替にHashMapを使います。

lazy_static! {
    static ref PRODUCTS: HashMap<u32, &'static str> = {
        let mut m = HashMap::new();
        m.insert(0, "desk");
        m.insert(1, "chair");
        m.insert(2, "whiteboard");
        m.insert(3, "pencil");
        m
    };

    static ref PRICE: HashMap<&'static str, u32> = {
        let mut m = HashMap::new();
        m.insert("desk", 300);
        m.insert("chair", 100);
        m.insert("whiteboard", 20);
        m
    };
}

idからnameを得る関数をget_productnameからpriceを得る関数をget_priceとします。 DB上にレコードが存在しない可能性があるので返り値がOption型であることに注意してください。

fn get_product(id: u32) -> Option<&'static str> {
    match PRODUCTS.get(&id) {
        Some(u) => Some(u),
        None => None,
    }
}

fn get_price(product: &'static str) -> Option<u32> {
    match PRICE.get(&product) {
        Some(u) => Some(*u),
        None => None,
    }
}

get_productget_priceをチェインさせる

まずはmapを使った場合です。

fn main() {
    let product_id = 1;
    let price = get_product(product_id)
        .map(get_price);
    println!("{:?}", price); // Some(Some(100))
}

Someが二重に包まれてしまいました。値を取り出したいときに何度もunwrap()しないといけないですね。

一方でand_thenはどうでしょう。

fn main() {
    let product_id = 1;
    let price = get_product(product_id)
        .and_then(get_price);
    println!("{:?}", price); // Some(100)
}

Someに包まれるのは一回だけです。

失敗するパターン

もう一度DBのテーブルたちを再掲します。

PRODUCTSテーブル

id name
0 desk
1 chair
2 whiteboard
3 pencil

PRICEテーブル

name price
desk 300
chair 100
whiteboard 20

PRODUCTSテーブルには、pencilが存在しますが、PRICEテーブルには存在しません。

id=3mapand_thenの挙動を比較してみましょう。

fn main() {
    let product_id = 3;
    let price = get_product(product_id)
        .map(get_price);
    println!("{:?}", price); // Some(None)
}
fn main() {
    let product_id = 3;
    let price = get_product(product_id)
        .and_then(get_price);
    println!("{:?}", price); // None
}

mapでは、Some(None)が、and_thenではNoneとなりました。

まとめ

関数をチェーンさせる場合、mapを使うと多重にOptionで包まれてしまうことを確認しました。 mapを連続で使っていると最終的に何回包まれたのか覚えておかなくてはなりません。

さらに、何かしらの理由でget_productget_priceの間に、別のOptionを返す関数を実装しなくてはならなくなった場合はどうでしょう。 コンビネータを差し込むだけではなく、包みを解くコードにも変更が必要です。 差し込む関数を実装するときも、前の関数が何回Optionを包んだのか意識しながら実装することになり、変更に弱くなります。

References


  1. Rust by Exampleにも書いてあります。

GoでCount-min sketchを実装する

この記事は Go Advent Calendar 2019 の2日目の記事です。

f:id:cipepser:20191119213339j:plain

こんにちは!さいぺです。
サムネのGopherくんは最近趣味で描いたものを、せっかくなので載せました。 オリジナルのThe Go gopherGopherくん)は、Renée Frenchによってデザインされました。

さて、以下の動画を見てMiniSketchなるデータ構造があることを知りました。

動画中のMiniSketchでは集合のreconcileまで述べられていますが、今回はCount-min sketchを実装します。より具体的には、指定した要素がいくつ追加されたのかを返すデータ構造を実装します。

Count-min sketchとは

Count-min sketchは、追加された要素の数を記憶するデータ構造です。
BloomFilterに似た確率的データ構造で、BloomFilterが要素の有無をboolで返すのに対して、Count-min sketchは追加された要素の数をintで返します。
偽陽性を許容することで、追加された要素の数を得るための最悪計算量が O(1)、メモリサイズを固定にできます。

アルゴリズム

k個のハッシュ関数k個のテーブルを利用します。
各テーブルのフィールドは0に初期化されています。 それぞれのハッシュ関数とテーブルは1:1で対応しており、要素を追加する際にフィールドの値をインクリメントします。

ハッシュ関数の数k=3、テーブルのサイズN=10として、二つの要素 e_1 e_2を追加する例を考えます。3つの各テーブルのindexは0始まりで図示しています。

f:id:cipepser:20191201030221p:plain

1つ目の要素 e_1に対して、3つのハッシュ値を計算し、該当するフィールド(中段図の赤字)をインクリメントします。

続けて、2つ目の要素 e_2に対して、3つのハッシュ値を計算し、該当するフィールド(下段図の赤字)をインクリメントします。

要素がいくつ追加されたのか知りたいときも同じような手順をとります。
例えば上記の例で e_2がいくつ追加されたのか知りたいとします。 このとき要素 e_2に対する3つのハッシュ値を計算します(図中の中段から下段に移動したときと同じ)。該当するフィールドの値を参照すると121が得られます(下段図の赤字)。この3つの数字のうち、最小(min)の値を要素が追加された数として返すのがCount-min sketchです(今回の例では1を返す)。
 h_2(e_1) h_2(e_2)が衝突しましたが、ハッシュ関数を3つ用意したことで、衝突していない残り2つの値が正しい答えとして、追加された要素の数を表現できていることがわかります。

実装

アルゴリズムが理解できたところで実装に移ります。 本記事ではポイントを絞って説明しますが、実装した一式はGitHubにあるので興味がある方はご覧ください。

github.com

データ構造

まずSketch型を以下のように定義します。 ここでkハッシュ関数の数、Nは各テーブルのサイズです。

type Sketch [k][N]int

Addの実装

k個のハッシュ関数は、Double-hash法で用意します。 Double-hash法については、以前の記事で触れていますのでご参照ください。

cipepser.hatenablog.com

今回はDouble-hash法をこのように実装していますが、Sketchのアルゴリズムとは直接関係ないので本記事では省略します。

要素をSketchに追加するためのメソッドAddの実装は以下のようになります。

func (s *Sketch) Add(elem string) error {
    for i := 0; i < k; i++ {
        h, err := util.DoubleHashing(elem, i, N)
        if err != nil {
            return err
        }
        s[i][h] += 1
    }

    return nil
}

k個のテーブルそれぞれに紐づくハッシュ関数があるので、要素のハッシュ値を計算します。 そのハッシュ値ををテーブルのindexだと思って、対応するフィールドをインクリメントするだけです。

Countの実装

要素がいくつ追加されたのかを得るためのメソッドCountの実装は以下です。

func (s *Sketch) Count(elem string) (int, error) {
    min := math.MaxInt64

    for i := 0; i < k; i++ {
        h, err := util.DoubleHashing(elem, i, N)
        if err != nil {
            return 0, err
        }

        if s[i][h] < min {
            min = s[i][h]
        }
    }

    return min, nil
}

Addと同じくk個のハッシュ値を計算します。 k個のハッシュ値のうち最小のものを、追加された要素の数として返します。

動作を確認する

実際に動かしてみましょう。 以下のようにaeをそれぞれ15個追加します。 なお、ハッシュ関数の数k=3、テーブルのサイズN=10としています。

elements := []string{
    "a",
    "b", "b",
    "c", "c", "c",
    "d", "d", "d", "d",
    "e", "e", "e", "e", "e",
}

要素を追加する操作はこんな感じです。

for _, e := range elements {
    err := s.Add(e)
    if err != nil {
        panic(err)
    }
}

uniques := []string{"a", "b", "c", "d", "e"}として、すべての要素がいくつずつ追加されたのか出力させてみます。

for _, e := range uniques {
    cnt, err := s.Count(e)
    if err != nil {
        panic(err)
    }
    fmt.Println(e, ":", cnt)
}

実行結果です。しっかり追加した要素の数を取得できていますね。

❯ go run main.go
a : 1
b : 2
c : 3
d : 4
e : 5

ハッシュ衝突させてみる

ハッシュ衝突させてみたかったので、要素の数を増やしたところghで衝突しました。

elements := []string{
    "a",
    "b", "b",
    "c", "c", "c",
    "d", "d", "d", "d",
    "e", "e", "e", "e", "e",
    "f", "f", "f", "f", "f", "f",
    "g", "g", "g", "g", "g", "g", "g",
    "h", "h", "h", "h", "h", "h", "h", "h",
}

以下のようにg(7個)とh(8個)で15となりました。

❯ go run main.go
a : 1
b : 2
c : 3
d : 4
e : 5
f : 6
g : 15
h : 15

最後に

偽陽性はありますが、データ構造自体のサイズを固定できるのは魅力的です。 今回はテーブルサイズをかなり小さくしたので比較的容易にハッシュ衝突しましたが、もう少し大きなNを選べば十分実用的でしょう。1
今回はベンチマークまで取れていないのでkNを大きくしたときにどうなるか調べるのもおもしろそうです。 あとはMiniSketchでの集合をreconcileもいずれ実装したいです。

References


  1. 本当は偽陽性確率がどれくらいに抑えられるかも本記事に間に合わせたかった

Go1.13のError wrappingを触ってみる

2019年9月3日にGo 1.13がリリースされました。 リリースノートを見ていて、Error wrappingが気になったので触ってみます。

何はともあれ、ドキュメントを見てみます。 errorsのドキュメントを確認すると、以下4つの関数が存在します。

func As(err error, target interface{}) bool
func Is(err, target error) bool
func New(text string) error
func Unwrap(err error) error

Newはただのコンストラクタなのでいいとして、残り3つは挙動を確認します。

Isを使ってみる

順番が前後しますが、Isから見ていきましょう。 Go1.12以前では、以下のような(if err != nil)エラーハンドリングをしていたと思います。

package main

import (
    "errors"
    "fmt"
)

var (
    MyError = myError()
)

func myError() error { return errors.New("myErr") }

func simpleError() error {
    return MyError
}

func main() {
    err := simpleError()
    if err != nil {
        fmt.Println(err) // myError
    }
}

もしくは、switch文を用いて以下のようにするパターンもありますね。
※以降では、main以外の共通部分は省略します。

func main() {
    err := simpleError()
    if err != nil {
        switch err {
        case MyError:
            fmt.Println("MyError:", err) // MyError: myErr
        default:
            fmt.Println("default:", err)
        }
    }
}

Isを用いると以下のように(if errors.Is(err, MyError))なります。

func main() {
    err := simpleError()
    if errors.Is(err, MyError) {
        fmt.Println(err) // myError
    }
}

Isの使い方はわかりましたが、Isの何が嬉しいのでしょうか。 本アップデートの名前にもあるようにwrapしたときに活きてきます。

ドキュメントにあるように、wrapするには%wを使います。 例えば、以下のような実装になります。

func wrappedError() error {
    err := simpleError()
    return fmt.Errorf("%w", err)
}

実際にwrappedErrorを用いて、wrapしたときのerrの型を見てみましょう。

func main() {
    err = simpleError()
    fmt.Printf("%T", err) // *errors.errorString

    fmt.Println()

    err := wrappedError()
    fmt.Printf("%T", err) // *fmt.wrapError
}

wrapする前は*errors.errorString型だったものが、*fmt.wrapError型になっていることがわかります。
先ほどのswitch文の例を実行してみると、MyErrorのエラーを捕まえることができなくなってしまいました。

func main() {
    err := wrappedError() // wrappedErrorに変わったことに注意
    if err != nil {
        switch err {
        case MyError:
            fmt.Println("MyError:", err)
        default:
            fmt.Println("default:", err) // default: myErr
        }
    }
}

そこでIsの登場です。

func main() {
    err := wrappedError()
    if errors.Is(err, MyError) {
        fmt.Printf(err.Error()) // myErr
    }
}

こちらはMyErrorを捉えられています。

Asを使ってみる

次にAsについてです。 個人的に最近はパーサーを書くことが多いので、以下のような例を用意しました。 Parseに失敗したときのエラーをハンドリングする例です。 返ってきたerrInvalidCharに型キャストして、エラーハンドリングしています。

package main

import (
    "errors"
    "fmt"
)

type InvalidChar struct {
    // other fields
    err error
}

func (ic *InvalidChar) Error() string {
    ic.err = errors.New("INVALID CHARACTER")
    return fmt.Errorf("%w", ic.err).Error()
}

func Parse() error {
    return &InvalidChar{}
}

func main() {
    err := Parse()
    if ierr, ok := err.(*InvalidChar); ok {
        fmt.Println(ierr) // INVALID CHARACTER
    }
}

Asを用いると以下のようになります。

func main() {
    err := Parse()
    var ierr *InvalidChar
    if errors.As(err, &ierr) {
        fmt.Println(ierr) // INVALID CHARACTER
    }
}

ここまでは、Isの例と同じなので本例でもwrappedErrorを実装します。

func wrappedError() error {
    err := Parse()
    return fmt.Errorf("%w", err)
}

wrapされたerrをGo1.12以前の方法で扱おうとすると以下のようにチェックをすり抜けます。

func main() {
    err := wrappedError()
    if ierr, ok := err.(*InvalidChar); ok {
        fmt.Println(ierr) // 何も出力されない
    }
}

以下のようにAsを用いれば、正しくハンドリングすることができます。

func main() {
    err := wrappedError()
    var ierr *InvalidChar
    if errors.As(err, &ierr) {
        fmt.Println(ierr) // INVALID CHARACTER
    }
}

Unwrapを触ってみる

最後にUnwrapです。 例はAsで用いたものと同じです。

func main() {
    err := wrappedError()
    fmt.Printf("Type:%T\nValue:%v\n", err, err)
    // Type:*fmt.wrapError
    // Value:INVALID CHARACTER

    err = errors.Unwrap(err)
    fmt.Printf("Type:%T\nValue:%v\n", err, err)
    // Type:*main.InvalidChar
    // Value:INVALID CHARACTER

    err = errors.Unwrap(err)
    fmt.Printf("Type:%T\nValue:%v\n", err, err)
    // Type:<nil>
    // Value:<nil>
}

wrapされたエラーを順番にUnwrapしていくことができ、最後にはnilになります。

所感

  • 標準ライブラリや外部ライブラリのエラーをwrapできるようになったのはありがたい
  • Go1.13以降で開発されたライブラリを用いることも考慮し、安全に倒すためにIsAsでエラーハンドリングしたほうがよさそう
  • エラー型が多くなってきたときにswitch文が使えないのが微妙

3つ目について、Go1.13からは今までのエラーハンドリングが機能しなくなるかもしれない - Qiitaでも言及されています。 以下のようにUnwrapする案も考えましたが、多段でwrapした際にうまく動かなくなるので実用的ではないでしょう。。。

func main() {
    err := wrappedError()
    switch errors.Unwrap(err).(type) {
    case *InvalidChar:
        fmt.Println("InvalidChar:", err) // InvalidChar: INVALID CHARACTER
    default:
        fmt.Println("default:", err)
    }
}

References

Rustでprotobufのゼロ値がどうやって実現されているか

以前、以下の記事たちを書きました。

cipepser.hatenablog.com

cipepser.hatenablog.com

Rustで0byteを読み込むとどうなるのか(Option型になるのか)気になったので検証してみます。

0byteをrust-protobufで読み込む

protobufを読み込むコードは、Golangで出力したprotobufバイナリをRustで読み込む - 逆さまにしたとほぼ同じです。一点変更しているのは、読み込むファイル名で、main.rsgo_user.binzero.binに変更しました。

ちなみに.protoの定義は以下の通りです。

syntax = "proto3";
package user;

message User {
  string name = 1;
  int32 age = 2;
}

空ファイルを作成し、それを読み込んだ実行結果は以下の通りです。 Goでいうゼロ値でデコードできています。

❯ touch zero.bin

❯ cargo run
Name:
Age: 0

というのもproto3の仕様に以下のように書いてあるのです。

Unknown fields are well-formed protocol buffer serialized data representing fields that the parser does not recognize. For example, when an old binary parses data sent by a new binary with new fields, those new fields become unknown fields in the old binary.

つまりprotobufでは、unknow fieldsも含めて仕様化されており、パーサーは値が存在しないのか、フィールドが定義されていないのかは区別できないのです。
ゼロ値ならバイナリにすらエンコードされないので、送るデータそのものを無くすことができる効率性があります。

protocで生成したコードの実装

protobufの仕様からGoのゼロ値のようにデフォルト値が使われることがわかりました。 もう一歩踏み込んで、protoc --rustoutでgenerateした実装を見ていきましょう。

わかりやすところで、Golangで出力したprotobufバイナリをRustで読み込む - 逆さまにしたで利用した以下のgetメソッドの実装を見ていきます。

println!("Name: {}", u.get_name());
println!("Age: {}", u.get_age());

protoc --rustoutでgenerateした実装を確認すると、以下のようになっています。

pub fn get_name(&self) -> &str {
    &self.name
}

pub fn get_age(&self) -> i32 {
    self.age
}

返り値がOptionに包まれていないですね。 Goの場合はゼロ値がありますが、Rustではどうやって実現されているんでしょうか。 ということでnewの実装をみてみます。

impl User {
    pub fn new() -> User {
        ::std::default::Default::default()
    }
}

予想通りですが、defaultが使われています。
なお、Userの定義でもDefaultがattributeに含まれています。

#[derive(PartialEq,Clone,Default)]
pub struct User {
    // message fields
    pub name: ::std::string::String,
    pub age: i32,
    // special fields
    pub unknown_fields: ::protobuf::UnknownFields,
    pub cached_size: ::protobuf::CachedSize,
}

References

実践Rust入門を読んだ

実践Rust入門 言語仕様から開発手法まで, κeen, 河野達也, 小松礼人を読みました。本書の特徴は以下の3つでしょう。

  • 2018 Editionに対応している
  • FFIについて日本語で書かれた書籍
  • 実践 を意識した内容になっている

本記事では、特に3つ目の実践的という観点で感想を述べようと思います。

Rustの言語仕様という観点で言えば、プログラミングRustのほうが網羅性は高いでしょう。 しかし、Rustは入門のハードルがとても高い言語です。1

個人的な経験でいえば、mapfilterでさえHaskellを勉強していなかったら、とっつきにくかったんじゃないかと思います。他言語の経験が豊富であればまだしも、いきなりRustを始めると挫折してしまうでしょう。 だからこそ、本書のような実践的な入門書は重要だと思います。「あれもこれもやらなきゃいけない」とならずに、実際に利用されるトピックから取り組めるのはとても良いことだと思います。

また、わかってる人からすると当たり前だけど初学者はわからないというようなことが、言語化されていたのもよかったです。例えばP.151では、桁あふれのメソッドについて

  • 検査付き演算: checked_
  • 飽和演算: saturating_
  • ラッピング演算: wrapping_
  • 桁あふれ演算: overflowing_

のprefixがつく、と言及されていました。他にもP.169のボックス化されたスライスでは、

使う機会はあまりなさそうですが、

と書かれていたりして、力の抜きどころもわかって良かったです。 プログラミングRustを読んでいると網羅性が高いのはいいんですが、これはいつ使うんだろう?となることがあるのでこういう配慮は助かります。

他にも、プログラミングRustにも載っている内容ではあるものの、Stringstrの違いは最初にわからなくなりがちなところなので、メモリ表現が図解されているのもわかりやすいと思います。
また、ユニット型()がサイズゼロだと知ってるとcollectionでSet作りたいときに()使えば良さそうと思えるのも良かったです。以前、GoでSetライブラリを自作しようとしたときにstruct{}{}だらけになったのでRustのほうが同じサイズゼロで実装するのにきれいに書けますね。 2

いいところばかりなのもあれなので、気になったところも述べます。 内容ではないですが、誤植が気になりました。 例えば、ch07_10_closures.rslet lookup = || assert!(s1.find('d').is_some()); に閉じカッコがないなど、コンパイルが通らないエラーは潰し込んでほしかったなと。 とはいえ、誤植はrust-jpのslackでも報告、対応してくださっているので、大きな問題はなかったです。 また、GitHubのコードは問題なく動くのでこちらも見ながら進めれば大丈夫でした。

構成で気になったのが、P.271の最後です。ここはgrowを実装する前だと動かないので、初学だと動かなくてちょっと困るかもと思いました。newしただけだとサイズ0しかヒープに領域確保してないので、動かないと思います。一度読み飛ばして、growを実装後に、戻ってくれば実行できるので、順序が逆だとわかりやすそうです。

また、本書でもマクロが使われているので、簡単な紹介くらいあるともっと良かったかと思います。

特に良かった章

最後に、個人的に勉強になった・楽しかった章の感想を残します。

第7章 所有権システム

Rust特有の概念である所有権やライフタイムについて第1部の中で特に実践的な章でした。 簡易版のVecを実際に自分の手で実装することで、利用頻度の高いVecの内部実装がイメージできるのは大きいと思います。 また、CopyとClone、RcとArc、FnとFnMutとFnOnceの違いが書かれていて、頭の中が整理できました。

第9章 パーサを作る

本書で一番読みたかった章です。 再帰下降パーサはmonkeyで実装したことがありましたが、Annotationをつけたことがなかったのと、エラー処理をちゃんとできるのはとても楽しかったです。

第10章 パッケージを作る

パッケージの作成、CI、crate.ioへの公開と一通り触れてよかったです。特にcrate.ioへの公開は初めてだったので勉強になりました。

第11章 Webアプリケーション、データベース接続

リクエストの受付からDBへの接続、レスポンスを返すところまで一式という章です。 Futuresに振れることができたのも大きな収穫でした。 また、CLIがこんなに簡単に書けるのかという驚きもありました。

References

実践Rust入門[言語仕様から開発手法まで]

実践Rust入門[言語仕様から開発手法まで]

プログラミングRust

プログラミングRust


  1. 本書の「はじめに」でも学習コストが高いことに言及されています

  2. RustはCollectionがstdにありますが。