C言語における空白文字

C言語における空白文字は、文字列の一部分としてと、トークンを明示的に区切る (long longlonglong は違う) こと以外には基本的に無意味というように思える。しかしプリプロセッサレベルでは、以下の場面で意味がある。

includeの中身

通常 <stdio.h> のようなコード断片は < stdio . h > に分解されるが、includeディレクティブ(または一部のpragma)の中でのみ特別に <stdio.h> という1つのトークンとして認識される (C11/§6.4.7)。このトークンに \, //, /*, ', " を含めた場合の挙動は未定義だが、空白を入れることは許されている (C11/§6.4.7)。したがって、

#include <a  b   c>

においては(規格書を読む限りは)空白文字が意味を持つ。

ただし、Cの規格では、includeのファイル名の解釈自体が処理系定義であり (C11/§6.10.2)、規格で規定されているヘッダ名に空白はない (C11/§7.1.2)。

defineの直後

以下の2つは異なる意味を持つ。

#define f(x, y) (y, x)
#define f (x, y) (y, x)

試しに以下のように実行してみるとわかる。

$ gcc -E -
#define f(x, y) (y, x)
#define g (x, y) (y, x)
f(1, 2)
g(1, 2)
# 1 "<stdin>"
# 1 "<built-in>"
# 1 "<command-line>"
# 31 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 32 "<command-line>" 2
# 1 "<stdin>"


(2, 1)
(x, y) (y, x)(1, 2)

このように、Cの規格では「マクロ関数定義の場合は、マクロ名と括弧の間に空白を入れない」と明確に規定する (C11/§6.10) ことで、関数マクロと通常のマクロを区別している。

文字列化

マクロの # で文字列化したときには、空白の存在/非存在が保たれる。(C11/§6.10.3.2)

$ gcc -E -
#define str2(x) #x
#define str(x) str2(x)
str(1+1)
str(1 + 1)
str(1  +  1)
str(1 /* hoge */ + /* fuga */ 1)
# 1 "<stdin>"
# 1 "<built-in>"
# 1 "<command-line>"
# 31 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 32 "<command-line>" 2
# 1 "<stdin>"


"1+1"
"1 + 1"
"1 + 1"
"1 + 1"

コメントを空白文字に置き換えることは、規格で明示されている (C11/§5.1.1.2) 。複数の空白を1つの空白文字にまとめるかどうかは、処理系定義である (C11/§5.1.1.2) 。 ただし、文字列化されるさいは、どの種類の空白も空白文字に置き換えられる (C11/§6.10.3.2)。

まとめ

C言語のコードは trigraph処理→改行処理→PP字句解析→プリプロセス→真正な字句への変換→構文解析→…… という流れで処理される (C11/§5.1.1.2)。ただでさえプリプロセスと構文解析の2段階の処理があってややこしいが、プリプロセスで使われる字句データと構文解析で使われる字句データに微妙に違いがある点も見落とせない。空白はその例のひとつで、構文解析では意味をもたないがプリプロセスでは意味をもつ。

C言語の型の読み方

C言語の型の読み方の解説は探すと色々出てくるが、改めて自分で説明してみることにした。

用語説明

C言語のポインタ記法に代わる記法として、ここでは次のような記法を導入する。もちろんこれはC言語にはない。

  • T へのポインタを ptr<T> と書く。
  • Tn 個並んだ配列を arr<T, n> と書く。
    • 可変長配列を arr<T, *> と書く。
    • サイズ指定のない配列を arr<T,> と書く。
  • 関数を fun<int(int, int)> のように書く。
    • ノンプロトタイプ関数を fun<int(*)> のように書く。

変数名について

C言語の型は、構文上は変数名とセットで扱われる。対応する変数がない場所では、単に変数名部分が省略されたものとして扱われる。

前処理1: K&R関数定義の場合

関数定義 ({} が後続する) で以下の条件に当てはまるときは、次のような処理をする。

  • 引数部分が () の場合、ここに void を入れる。
  • 引数部分が (x, y) のように変数名のみの場合、その直後に int x, y; のような宣言があるのでこれを処理する必要がある。(詳しい説明は省略)

ステップ1: 宣言指定子と宣言初期化子に分離する

以下に挙げるものが宣言指定子 (declaration specifier)である。

  • 分類A: typedef extern static _Thread_local/thread_local auto register inline _Noreturn/noreturn _Alignas/alignas
  • 分類B: const restrict volatile _Atomic/atomic
  • 分類C: void char short int long float double signed unsigned _Bool/bool _Complex/complex struct union enum _Atomic()/atomic(), typedef名 (int32_t など)

これらを左から順に取り除いていって、残った部分 (変数名、ポインタ、配列、関数、初期化など) が宣言初期化子 (declarator-initializer)である。宣言初期化子はカンマ区切りで複数あるかもしれない。

例えば、

long const unsigned static long * volatile x[30] = {0}, (*y)[20];

の場合、以下のように分けられる。

  • 分類Aの宣言指定子: static のみ
  • 分類Bの宣言指定子: const のみ
  • 分類Cの宣言指定子: unsigned, long, long
  • 宣言初期化子その1 : * volatile x[30] = {0}
  • 宣言初期化子その2 : (*y)[20]

宣言を分割する

1つの宣言に、複数の宣言初期化子がカンマで区切られている場合は、宣言を分割する。例えば、上の例の場合、

long const unsigned static long * volatile x[30] = {0};
long const unsigned static long (*y)[20];

と分割する。

ステップ2: 宣言指定子を並び替える

宣言指定子は3種類に分けられるので、分類する。

static const unsigned long long * volatile x[30] = {0};
static const unsigned long long (*y)[20];

ここで、「unsigned long long」という3つのキーワード(順不同だが重複は数える)の並びが、1つの型をあらわしている。わかりやすさのために、以降はアンダースコアで結ぶことにする。

static const unsigned_long_long * volatile x[30] = {0};
static const unsigned_long_long (*y)[20];

ステップ3: 宣言子を分解する

宣言子を以下のルールで分解する。

  • T * q dq ptr<T> d
  • T d[q n]q arr<T, n> d (nは特別な記号 * の場合や、何も書かれていない場合もある)
  • T d(A x, B y)fun<T (A x, B y)> d など
    • 例外1: T d()fun<T (*)> d
    • 例外2: T d(void)fun<T ()> d
  • T (d)T d

ただし、

分類Aの宣言指定子と初期化子は変換に巻き込まない。複数のルールが適用可能なら上のほうのルールを優先する。

上記のルールは適用できなくなるまで繰り返す。宣言子についている皮を剥いでいって、剥いだ順に宣言指定子側に被せていく感じになる。つまり順番が逆になる。

例えば以下のように変形する。

static const unsigned_long_long * volatile x[30] = {0};
static volatile ptr<const unsigned_long_long> x[30] = {0};
static arr<ptr<const unsigned_long_long>, 30> x = {0};

static const unsigned_long_long (*y)[20];
static arr<const unsigned_long_long, 20> (*y);
static arr<const unsigned_long_long, 20> *y;
static ptr<arr<const unsigned_long_long, 20>> y;

ステップ4: 関数の引数を処理する

もし関数の型が含まれていたら、その引数もまた宣言である。そこでこの処理を再帰的に繰り返す。

関数の引数の変数名は無視できるので、削除する。

ステップ5: 配列型を変換する

関数の引数部分については、配列をポインタに降格させる。例えば、

fun<int (int, arr<arr<int, 20>, 10>)>

のような型の場合は、 arr<arr<int, 20>, 10> が関数の引数なのでこれをポインタに降格させて、

fun<int (int, ptr<arr<int, 20>>)>

とおく。この処理は上のように、配列が関数の引数の内部に間接的に出現する場合は関係ない。

※この処理は、配列がtypedefされた型に対しても行う。

また、関数の引数に間接的に出てくる配列の大きさが定数ではない場合は、これを * に置き換える。

ステップ6: 完成

おわり

まとめ

C言語の型の構文は、意味を素直に表現していないので混乱のもとである。しかし、「宣言子から皮を剥がしていく作業」の影響範囲をきちんと理解すれば特に怖いものではない。

Feedly用ブックマークレット

以前から使っていたものが微妙だったので作り直した。

Subscribe it with Feedly

JavaScript部分は以下のようになっている。

void(feedtag=document.evaluate('//link[@rel="alternate"][@type="application/atom+xml" or @type="application/rss+xml"]', document, null, XPathResult.ANY_UNORDERED_NODE_TYPE).singleNodeValue);
if(feedtag) {
  location.href='https://feedly.com/i/subscription/feed/'+encodeURIComponent(feedtag.href);
} else {
  window.alert('No feed found');
};

RustのSizedとfatポインタ

概要: RustにはSizedというトレイトがあり、一部の例外を除いて暗黙のうちに実装されている。Sizedが実装されていない型はDynamically Sized Typeと呼ばれ、これらのデータはfatポインタを経由してアクセスする。この仕組みを説明する。

Sizedの使い方はAPIリファレンスThe Bookの該当部分その日本語訳Rustonomiconの該当部分をまず読むとよい。

この記事では、コンパイラがSizedをどう実装しているかという観点からまとめ直してみた。

Sizedとは何か

Sizedは標準ライブラリで定義されているトレイトである。

pub trait Sized {}

Sizedトレイトは次の2つの意味をもつようだ。

  • Sizedを実装する型は、全て同じバイト数である。C言語のsizeofに相当するstd::mem::size_of が使える。(Sizedでない場合は値によって異なるため、std::mem::size_of_valを使う)
  • Sizedを実装する型は、データ本体以外の余計な情報を持たない。(逆に、Sizedでない場合は、余計な情報が必要になる。それらの余計な情報を持つためにfat pointerが採用される)

Sizedは実装を持たない「マーカートレイト」の一種であり、言語処理系によって特別扱いされている。

特別扱いその1: 暗黙の制約と暗黙の実装

プログラマが明示しなくても、型変数は Sized を実装していることが仮定される。例えば、

pub struct Foo<X>(PhantomData(X));

と書いた場合、 X:Sized が仮定される。例外は以下の3つである。

  • トレイト宣言におけるトレイト自身は、 Sized を仮定しない。 (トレイトの型引数や関連型はこの限りではない)
  • X: ?Sized という専用構文を使って、 Sized を仮定しないことを明示した場合。
  • 組み込みのポインタ型と参照型。

また、 enumstruct でユーザーが定義した型は、全ての要素が Sized ならば、自動的に Sized の実装が与えられる。逆に、ユーザーが手動で Sized の実装を追加したり削除したりすることはできない。

特別扱いその2: プログラム中での要請

以下の状況で Sized が要請される。

  • let やパターンマッチで束縛できる変数の型は Sized でなければならない。
  • 関数の引数と戻り値の型は Sized でなければならない。
  • structの最後を除く全ての要素と、enumの全ての要素は、 Sized でなければならない。

例えば、

fn f(a: [u8]);

という関数は書けない ([u8] はSizedではないため) が、

fn f(a: &[u8]);

という関数は書ける (&[u8] はSizedであるため) 。

特別扱いその3: ポインタの大きさ

Sized を実装しない型へのポインタは、通常fat pointerで実装される。

例えば、x86-64環境なら、 *const T, *mut T, &'a T, &mut 'a T, Box<T> などのポインタ自身の大きさは通常8byteだが、TがSizedでないときは16byteになる。

fat pointerは何を保持しているのか

Sizedでない型は限られる。具体的には:

  • [i64] などのスライス。
  • str 文字列スライス。
  • ReadFn(i32) -> i32 などのトレイトオブジェクト。
  • Sizedでない要素を末尾にもつstruct。

これらの値へのポインタは16byteになる。最初の8byteにはデータの先頭番地が入っている。続く8byteには、以下の内容が入っている。

  • &[i64] の場合: スライスの要素数
  • &str の場合: バイト数。
  • &Read の場合: vtableへのポインタ。
  • これらを末尾要素にもつstruct: 末尾要素のfat pointerの内容を継承する。

このことは以下のような実験で確かめられる。

use std::ops::Deref;

// xの中身をバイト列として見るための関数
fn as_raw_bytes<'a, T:?Sized>(x: &'a T) -> &'a [u8] {
    unsafe {
        std::slice::from_raw_parts(
            x as *const T as *const u8,
            std::mem::size_of_val(x))
    }
}

pub struct S<T:?Sized> {
    pub x: u8,
    pub y: T,
}

fn main() {
    let arr : [u16; 3] = [1, 2, 3];
    let arrref = &arr;
    let arrptr = &arr as *const [u16; 3];
    let arrslice = &arr[..];
    let arrsliceptr = &arr[..] as *const [u16];
    let strslice = "ほげ";
    let clos = |x, y| x + y;
    let closref : &Fn(u8, u8) -> u8 = &clos;
    let mut a = 0;
    let clos2 = |x| {a += x; a};
    let clos2ref : &FnMut(u8) -> u8 = &clos2;
    let sarr : Box<S<[u16; 3]>> = Box::new(S { x: 3, y: [1, 2, 3] });
    let sarrref = sarr.deref();
    let sslice : Box<S<[u16]>> = Box::new(S { x: 3, y: [1, 2, 3] });
    let ssliceref = sslice.deref();
    println!("arrref = {:?}", as_raw_bytes(arrref));
    println!("&arrref = {:?}", as_raw_bytes(&arrref));
    println!("&arrptr = {:?}", as_raw_bytes(&arrptr));
    println!("arrslice = {:?}", as_raw_bytes(arrslice));
    println!("&arrslice = {:?}", as_raw_bytes(&arrslice));
    println!("&arrsliceptr = {:?}", as_raw_bytes(&arrsliceptr));
    println!("strslice = {:?}", as_raw_bytes(strslice));
    println!("&strslice = {:?}", as_raw_bytes(&strslice));
    println!("closref = {:?}", as_raw_bytes(closref));
    println!("&closref = {:?}", as_raw_bytes(&closref));
    println!("clos2ref = {:?}", as_raw_bytes(clos2ref));
    println!("&clos2ref = {:?}", as_raw_bytes(&clos2ref));
    println!("sarrref = {:?}", as_raw_bytes(sarrref));
    println!("&sarrref = {:?}", as_raw_bytes(&sarrref));
    println!("ssliceref = {:?}", as_raw_bytes(ssliceref));
    println!("&ssliceref = {:?}", as_raw_bytes(&ssliceref));
}
arrref = [1, 0, 2, 0, 3, 0]
&arrref = [82, 202, 147, 108, 255, 127, 0, 0]
&arrptr = [82, 202, 147, 108, 255, 127, 0, 0]
arrslice = [1, 0, 2, 0, 3, 0]
&arrslice = [82, 202, 147, 108, 255, 127, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0]
&arrsliceptr = [82, 202, 147, 108, 255, 127, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0]
strslice = [227, 129, 187, 227, 129, 146]
&strslice = [128, 149, 8, 159, 82, 86, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0]
closref = []
&closref = [248, 201, 147, 108, 255, 127, 0, 0, 240, 199, 41, 159, 82, 86, 0, 0]
clos2ref = [231, 201, 147, 108, 255, 127, 0, 0]
&clos2ref = [216, 201, 147, 108, 255, 127, 0, 0, 32, 200, 41, 159, 82, 86, 0, 0]
sarrref = [3, 0, 1, 0, 2, 0, 3, 0]
&sarrref = [8, 0, 98, 112, 68, 127, 0, 0]
ssliceref = [3, 0, 1, 0, 2, 0, 3, 0]
&ssliceref = [16, 0, 98, 112, 68, 127, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0]

Sizedでない参照を作る方法

Sizedでない値を直接作ることはまずない。通常は何らかのポインタの形で存在する。これらのポインタは、Sizedな値へのポインタを型強制によって変換することで作ることが多い。例えば、

fn curried_add(x: i32) -> Box<Fn(i32) -> i32> {
    Box::new(|y| x + y)
}

のような関数を書いたとき、この Box::new が返すのは Box<Fn(i32) -> i32> ではない。このBoxが返すのは Box<SomeClosure> のように具体的なクロージャの型が判明した状態のBoxである。(もちろんこのクロージャには実際にはユーザーから見える名前はついていない。) そもそも Box::newT: Sized を仮定しているから、これが Box<Fn(i32) -> i32> を直接返すことはありえない。

つまり Box<SomeClosure> から Box<Fn(i32) -> i32> への型強制が行われていることになる。この部分の仕様はまだ確定はしていないようだが、現状では次のように実装されている。

型強制が可能になる条件はいくつかあるが、ここでは std::ops::CoerceUnsized というトレイトの実装があるかチェックされる。これも言語処理系により特別扱いされているマーカートレイトである。つまりここでは、

impl CoerceUnsized<Box<Fn(i32) -> i32>> for Box<SomeClosure> {}

が定義されているかが調べられる。ここでBoxは

impl<T: ?Sized + Unsize<U>, U: ?Sized> CoerceUnsized<Box<U>> for Box<T> {}

を実装しているため、実際に必要なのは

impl Unsize<Fn(i32) -> i32> for SomeClosure {}

ということになる。これは言語処理系によって自動的に実装される。

CoerceUnsized は他の参照やスマートポインタにも実装されている。例えば &[1; 2; 3]&[i32] と見なせるのも同じ仕組みだと思われる。

内部の仕組み

Sizedcore::marker::Sized で定義されている。

Sizedの定義を見ると、普通のtraitのようにも見えるが、#[lang = "sized"] という記述がある。この lang という属性は、このアイテムが処理系によって特殊な扱いを受けることを表している。langは rustc::middle::lang_item で処理される。 lang="sized"という属性は lang_items::SizedTraitLangItem という名前がつけられ、この属性のついたアイテムを lang_items.sized_trait() で取得できるようになる。

Sized は言語処理系の以下の場所で特殊な扱いを受ける。

  • 条件を満たしたstructやenumに自動的にSizedが付与される。
    • rustc_tyoeck::collect Sizedを付与していいか決めている。この sized_by_default は基本的にYesだが、trait宣言のSelfに対してのみNoとなる
    • rustc_typeck::astconv Sizedを付与している
  • 必要な場面で自動的にSized制約が追加される。
  • rustc::traits::select 必要な場面で自動的にSized実装が追加される。
  • rustc_typeck::coherence::orphan 手動でのSized実装の追加/削除を禁止する。
  • 必要な場面で自動的にSizedが実装されているか検査される。
    • rustc::traits::object_safety トレイトオブジェクト作成時のobject safetyチェック
    • rustc_typeck::check::cast キャストにおけるsizedチェック (unsizedへの変換は不可能。sized ptrからunsized ptrへの変換は不可能。function ptrからunsized ptrへの変換は不可能。unsized ptrからアドレスへの変換は不可能。アドレスからunsized ptrへの変換は不可能。)

CMS (contestms) のユニットテストと機能テスト

CMS (contestms) は国際情報オリンピックと各国の情報オリンピックを中心に利用されているオープンソースのオンラインジャッジシステムである。

CMSのバックエンドに手を入れる場合には、ユニットテストと機能テストを手元で実行できるようにしたほうがよい。

前提: CMSを動作させるための設定はできている。

データベースの設定

ユニットテストおよび機能テストでは本番用(?)とは異なるデータベースを採用する。データベース名の計算はザルで、以下の行で処理している。

cms.config.database += "fortesting"

例えば、CMSの設定マニュアルでは postgresql+psycopg2://cmsuser:your_password_here@localhost/cmsdb という名前のデータベースを設定しているが、これに従うなら、同じようにして cmsdbfortesting という名前のDBを作成しておく。

テストの実行

cmsRunTests -v

テストに失敗した場合 -r をつければ失敗したテストを対象に再度実行できる。

gitでbareとnon-bareを切り替える

用語集

  • non-bare repository: 皆様のお手元にある普通のリポジトリがそれです。
  • bare repository: 普通はgitサーバーにある。手元でも取り扱い可能。ただしworking directoryがないので、work treeを必要とする様々な操作ができない。

bare repositoryは hoge.git/ のように .git という拡張子のついたディレクトリであらわすのが慣例であるため、それに従う。

cloneでbareからnon-bare, non-bareからbareを作成する

cloneするときにbareにするかnon-bareにするか指定できる。

# bareにする
git clone --bare /path/to/my-nonbare-repository /path/to/my-bare-repository.git

# non-bareにする
git clone /path/to/my-bare-repository.git /path/to/my-nonbare-repository

その場でnon-bareをbareにする

my-progress というnon-bare repositoryがあったとする。

cd /path/to/my-progress

# core.bare を入れる。この時点でwork treeは無視される。
git config core.bare true

# .git を取り出して my-progress.git にする。
mv .git ../my-progress.git

# 残されたwork treeはいらないので削除する。
cd ../my-progress.git
rm -r ../my-progress

その場でbareをnon-bareにする

my-progress.git というbare repositoryがあったとする。

cd /path/to/my-progress.git

# work tree 予定地を用意する
mkdir ../my-progress
cd ../my-progress

# bare repository を .git という名前にしてwork tree内に移動する
mv ../my-progress.git .git

# core.bare を外す。これでwork treeの存在が認識されるようになる。
git config core.bare false

# indexの中身をwork treeに展開する。
git checkout-index -a

まとめ

bare repositoryとnon-bare repositoryの違いは3点

  • core.bare configが true に設定されており、これによってwork treeがないものとして扱われる。
  • gitの管理ディレクトリが .git ではなく、 *.git という名前になっている。これにより、その外側のディレクトリはリポジトリの一部として扱われない。管理ディレクトリのみがリポジトリ本体として扱われる。

過去のコミットでgit LFSを使うように歴史改変をする

Subversionからgitに移行したはいいが、履歴があまりにでかいのでPDFを別管理にしたい」という状況を考える。でかいデータやバイナリを別管理にするといえばgit LFS, 昔の履歴に遡って変更を加えるにはgit filter-branchであるが、これらを組み合わせる方法を以下に述べる。

全体の手順としては以下の通り:

  1. 使っているサーバーがLFSに対応しているかどうか確認する(GitHubなら無課金では1Gまでしか使えない、など)
  2. Git LFSはgitとは別なので、別途インストールする (Ubuntuならpackagecloudからインストールできる)
  3. filter-branchを炸裂させる
  4. リポジトリを掃除する

filter-branchを炸裂させる

# 全てのタグやブランチに対して、 *.pdfや*.keyをLFSで管理するように歴史改変する
git filter-branch --tree-filter "git lfs track \"*.pdf\" \"*.key\" >/dev/null" --index-filter "rm .gitattributes" --tag-name-filter cat -- --all

これの動作を解説する。まず git lfs track.gitattributes を書き換える。このファイルに書かれている種類のファイルだけがgit LFSの管理下に置かれる。中身が同じでも、gitの管理下のファイルとgit LFSの管理下のファイルは扱いが異なるため、 git add にてそれらのファイルを追加する必要がある。

そもそもgit filter-branchは、元のコミットをそれぞれ取り出して変更を加え、それらを繋ぎあわせて新しい歴史を作るというものである。このとき、コミットの変更処理のうち今回使う部分を抜き出すと以下のようになる。

  • 専用の t というディレクトリに、コミットの中身をぶち撒く。 (git checkout-index)
  • 逆に、コミットに入っていないファイルが残っていたら、それを削除する。 (git clean)
  • --tree-filter で指定されたコマンドを実行する。
  • t に入っているファイルからインデックスを再構成する。 (git update-index)
  • --index-filter で指定されたコマンドを実行する。
  • コミットする。 (git write-tree, git commit-tree)

つまり、 tree-filter で用意された箇所で .gitattributes を変更するだけでうまくいきそうな感じがあるが、これだけだと Pointer file error: Unable to parse pointer という謎のエラーが出てくる。

これは、 t ディレクトリに .gitattributes が残った状態で次の処理が走るからである。このcheckout-index処理で、git LFSは取り出されたポインタファイルを中身のあるファイルに変換する処理を行う。このとき、正しくない .gitattributes が残っているためにこのエラーが発生する。

そこで、 index-filter.gitattributes を削除している。本来の index-filter の使い方ではないが、うまく動作する。なお、元のコミットに .gitattributes がある場合も、 git checkout-index のときにそれが取り出されるので特に問題はない (はず) 。

末尾にある --tag-name-filter cat -- --all は変換対象を指定している。 --tag-name-filter cat はタグ名をcatで変換する、つまり何もしないように見えるが、これを指定することでタグも変換対象になる。 --allgit rev-list のオプションで、全てのrefを指定している。

特定のブランチだけを書き換えるなら、例えば -- mybranch のようにすればよい。

リポジトリを掃除する

LFSがない状況で一番手っ取り早いのは file:///path/to/repository を別の場所にcloneすることであった。ここで file:// をつけることでgitは .git/objects の中身をハードリンクするなどの横着なしに必要なオブジェクトを取得する。これをしないとpackされたでかいファイルなどがそのままついてきてしまう。

ただLFSの場合はLFSのオブジェクトがついてこないので意味がない。どうせLFSを使うときはサーバーがある状況なので、そのサーバーにpushしてしまうのがよいと思う。

まとめ

頑張って調べたが結局今回は使わないことになった。ぜひ誰か役立ててほしい。