昨天那張排行榜上,《深夜的手記》借了九次排第一。一本不夠借,老闆照著榜單進貨,前十五名各多進一本。晚班店員把新書貼上條碼放上架。
第一位客人來借新的那本,第三天那支程式回了一句:
✗ 《深夜的手記》已經在 2025-12-21 借給 蔡季軒,還沒回來。
店員看看架上那本,再看看螢幕。兩邊都沒錯——蔡季軒手上那本確實還沒回來,只是電腦不知道店裡現在有兩本。
第四天那條最重要的規則,寫的是「同一本書借出中不能再借給別人」。它判斷用的是ISBN。兩本書同一個ISBN,在電腦眼裡就是同一本。
第二天那盒卡片不會出這種事。兩本書各自在自己的封底插一張借閱卡,老闆多買一本,就是多一張卡。實體天生就分得開。
是筆者把它弄丟的。第三天把三個名詞寫成程式碼的時候,Loan指向Isbn——一個書目編號。那時候每種書只有一本,書目編號足以指出是哪一本書,所以看不出差別。
第四天結尾寫過這件事會壞,只是沒想到壞的樣子這麼具體:客人站在櫃檯,書在店員手上,電腦說沒有。
BookCopy進場。
public sealed record Book(string Isbn, string Title, string Author);
/// <summary>館藏副本:店裡的一本實體書。CopyId 是貼在封底上的條碼。</summary>
public sealed record BookCopy(string CopyId, string Isbn);
public sealed record Loan(int LoanId, string MemberId, string CopyId, DateOnly LoanDate, DateOnly? ReturnDate)
{
public bool IsReturned => ReturnDate is not null;
}
三個變動:
| 變動 | 第三天 | 今天 | 為什麼 |
|---|---|---|---|
BookCopy |
沒有 | 一本實體書一筆,C00001起連號 |
兩本書是兩個東西,各有借還歷史 |
Loan指向誰 |
Isbn |
CopyId |
借出去的是那一本,不是那一種 |
Loan.LoanId |
沒有 | int流水號 |
下一節 |
Book和Member一個字都沒動。借出和歸還時,店員手上認的東西從ISBN變成封底那張條碼;查一本書可不可借還是照ISBN問——後面那個畫面第一個動作就是這樣問的。
第四天結尾說過:「那一天要先讓每一本實體書有自己的識別,Loan才知道自己指向誰。」今天兌現的就是這一句。
但今天加了兩個識別,它們解的不是同一件事。
CopyId解的是「這一本到底是哪一本」。 它先讓新進的C00099可以在C00098還在蔡季軒手上的時候照常借出去——本篇最後那個成功借出的畫面就是這一步。等兩本同時在外面,其中一本被還回來,程式才需要靠條碼知道該結掉哪一筆借閱。可不可以再借、還的是哪一本,兩件事用ISBN都答不出來。
LoanId解的是別的。 Return要用一筆新紀錄換掉舊的,得先找到舊的在哪裡:
// 每一筆借閱有自己的流水號,就用它找位置,不必靠欄位的組合是否恰好不重複。
var returned = outstanding with { ReturnDate = today };
var index = _loans.FindIndex(l => l.LoanId == outstanding.LoanId);
_loans[index] = returned;
Unindex(outstanding);
第四天用的是IndexOf,靠的是「沒有兩筆欄位完全相同的Loan」。
要說清楚的是:加了CopyId之後,IndexOf其實也還能用。 同一個副本同時最多一筆未歸還,IndexOf要找的那一筆不會有第二筆長得一樣。所以流水號不是被逼出來的,它是筆者選的。
選它的理由是「靠一組欄位的組合恰好不重複」這件事本身不可靠。而且不必等到以後:今天的程式裡就找得到兩筆長得一模一樣的紀錄。
[Fact]
public void 已歸還的兩筆借閱可能四個欄位全等_只有流水號不同()
{
// 同一個人同一天把同一本書借走、還回來、再借一次、再還一次。
// Return 只擋「歸還日早於借出日」,所以當天借當天還是合法的,這個序列跑得出來。
var desk = 空櫃檯();
desk.Lend(小明.MemberId, A1.CopyId, 今天);
var 第一筆 = desk.Return(A1.CopyId, 今天);
desk.Lend(小明.MemberId, A1.CopyId, 今天);
var 第二筆 = desk.Return(A1.CopyId, 今天);
Assert.Equal(第一筆.MemberId, 第二筆.MemberId);
Assert.Equal(第一筆.CopyId, 第二筆.CopyId);
Assert.Equal(第一筆.LoanDate, 第二筆.LoanDate);
Assert.Equal(第一筆.ReturnDate, 第二筆.ReturnDate);
Assert.NotEqual(第一筆.LoanId, 第二筆.LoanId);
}
這條測試是綠的(原始碼裡開頭還有一行註解,講的就是上一段那句話,這裡省略):兩筆已歸還的紀錄,會員、副本、借出日、歸還日四個欄位全等,只有流水號不同。IndexOf今天還安全,只是因為它要找的永遠是未歸還的那一筆。
給每一筆借閱一個不靠其他欄位的識別,這個問題就不用再想。筆者打算到第十八天資料進資料庫的時候,讓它直接當主鍵。
這家店也得經歷一次進貨。S規模從一百種書變成一百種、一百一十五本,其中十五種各有兩本。
進貨清單不是擲骰子決定的,是照借閱次數排:
// 借閱次數排前 15 名的書,老闆各多進一本(所以是兩本,不是三本)。
// 同分的書很多——S 規模第 15 名那個次數就有二十幾本同分——所以名次要多一條規則才排得出來。
// 這裡用「次數由多到少,同分時書目順序在後的排前面」,和第五天那張試算表排行榜的
// 排序鍵(借閱次數 + ROW()/100000,列號大的贏)方向一致,兩邊的名次才會是同一份。
var rankedBooks = Enumerable.Range(0, books.Count)
.OrderByDescending(i => loanCount[i])
.ThenByDescending(i => i)
.ToArray();
昨天那個排序鍵欄位的問題,在這裡原封不動又出現一次。
而且這次不能隨便決定。昨天試算表的排序鍵是借閱次數 + ROW()/100000,列號大的贏;如果今天程式反過來讓列號小的贏,同樣是「前十五名」,選出來的書就不是同一批——第十五名那個次數有二十幾本同分,換個方向就換一批書進貨。
要說「照昨天的榜單進貨」,兩邊的同分規則得是同一個方向。這種和店裡完全無關的規則,一旦被兩個地方各寫一次,就會開始對不起來。
所以今天有一條測試專門盯著它:把每本書的借閱次數重算一遍,照同一個方向排名,前十五名各兩本、其餘各一本,錯一本就紅。
但這一步插在哪裡,有一個更麻煩的後果:
整個產生器只有一個亂數實例。第一版的GenerateCopies是逐本擲骰決定要進幾本,一百種書就抽一百次。中間插一步、多抽一百次,後面所有集合的抽籤結果就全部往後位移。
筆者第一版把GenerateCopies放在會員和借閱前面,跑出來的資料是這樣:四十位會員全部換人。第五天那份試算表裡的楊芷萱和李子涵,在新資料裡不存在了;書目一百筆倒是一字不差。
書沒事,人全換。而前面五天每一篇都引用過人名。
改法是把順序整個重排:
var books = GenerateBooks(random, profile.Books);
var members = GenerateMembers(random, profile.Members);
var (periodStart, periodEnd) = PeriodFor(scale);
// 借閱先對「書目」排,抽籤順序和第三天完全一樣:同一個 seed 的會員名冊、借出日、
// 每本書的借閱次數都不會變,前面幾篇引用過的名字與排行榜仍然成立。
var drafts = GenerateLoanDrafts(random, books, members, profile.Loans, periodStart, periodEnd);
// 店裡進貨看的是哪幾本借得最兇,所以副本數依借閱次數決定,不擲骰。
var copies = GenerateCopies(books, drafts);
// 最後把每一筆借閱綁到那本書的其中一本實體書。
var loans = BindLoansToCopies(books, copies, drafts);
借閱先排給「書目」,副本後產生,最後才把每一筆借閱綁到某一本實體書。這樣亂數的抽取順序和第三天一模一樣,於是:
規模:S seed:20260905
書籍:100(副本 115,其中 15 種書不只一本)
會員:40
借閱:500(未歸還 37)
未歸還還是37筆,和第三天啟動畫面、第五天試算表上那個數字一樣。排行榜也沒變,《深夜的手記》還是九次第一。
這不是運氣,是排順序換來的。第五天那張試算表已經發佈,上面的名字和數字改不了了,所以要讓步的是產生器。
被擋下來的時候,電腦順口告訴店員另一本在哪裡:
=== 租書店櫃檯 v2 ===
今天 2026-09-19|書 100 種 115 本|會員 40 位|借閱紀錄 500 筆|未歸還 37 筆
1) 借出 2) 歸還 3) 查會員未歸還 4) 查書可不可借 5) 搜尋書名 0) 離開
> ISBN:《深夜的手記》共 2 本,在店 1 本:
C00098 借出中(2025-12-21 → 蔡季軒)
C00099 在店
> 會員編號:條碼:✗ 條碼 C00098《深夜的手記》已經在 2025-12-21 借給 蔡季軒,還沒回來。店裡還有另一本《深夜的手記》:C00099。
> 會員編號:條碼:借出成功 #501:深夜的手記(C00099) → 楊芷萱(2026-09-19)
選單每個動作前都會再印一次,這裡只留第一次。日期來自系統時鐘,其餘數字都來自種子資料。
程式裡就是這一段:
var others = AvailableCopiesOf(copy.Isbn);
var hint = others.Count == 0 ? ""
: others.Count == 1 ? $"店裡還有另一本《{title}》:{others[0].CopyId}。"
: $"店裡還有 {others.Count} 本《{title}》可借:{string.Join("、", others.Select(c => c.CopyId))}。";
throw new RentalException($"條碼 {copyId}《{title}》已經在 {outstanding.LoanDate:yyyy-MM-dd} 借給 {holder},還沒回來。{hint}");
三個分支是這樣來的:店裡一本都不剩就不必多話,只剩一本就講「另一本」,剩兩本以上得把每一本都列出來。上面畫面裡走的是中間那個,三個分支各有一條測試盯著——包括「不必多話」那個,測的是訊息裡不會冒出「店裡還有」四個字。
這是今天店員唯一感覺得到的改變:以前被擋下來要自己走去架上確認,現在螢幕直接說了下一本在哪裡。
改建構式的時候,查找也一起換了——從List一路掃過去,改成六張Dictionary。這裡只摘出其中三張:
private readonly Dictionary<string, BookCopy> _copiesById;
private readonly Dictionary<string, Loan> _outstandingByCopy; // CopyId → 那一筆未歸還
private readonly Dictionary<string, List<Loan>> _outstandingByMember; // MemberId → 手上所有未歸還
六張分成兩類。四張是查找表——依ISBN查書、依CopyId查副本、依ISBN查一種書的所有副本(上一節那句提示要找「另一本」,查的就是這一張)、依會員編號查會員——建構的時候一次填好,之後不再改。上面三行取的是其中一張查找表和兩張索引;索引只放「還沒回來的」借閱,每一次借出和歸還都得跟著動。所有借閱的歷史仍然留在_loans裡。
有一個查詢沒換:搜尋書名還是從頭掃到尾,因為它沒有可以當key的東西。明天量的那幾個查詢裡也沒有它。
但這不是免費的。程式裡那段註解把代價寫得很清楚:
// 兩張索引只放未歸還的借閱。借出時加進去,歸還時拿掉——這就是索引的「同步維護成本」,
// 忘了其中一邊,查詢就會說謊。
private void Index(Loan loan)
{
_outstandingByCopy[loan.CopyId] = loan;
if (!_outstandingByMember.TryGetValue(loan.MemberId, out var list))
{
list = [];
_outstandingByMember[loan.MemberId] = list;
}
list.Add(loan);
}
List掃描的版本沒有這個問題:資料只有一份,掃過去看到什麼就是什麼。現在資料有三份——_loans、兩張索引——每一次借出和歸還都要三邊一起改。上一節Return那段程式碼最後那行Unindex(outstanding);,就是還書時把索引拿掉的那一步。忘了寫,書還回來了,查詢還是說它在外面。
所以今天還多了兩條測試,它們不測規則,測的是三份資料有沒有對上:
[Fact]
public void 載入種子資料後兩張索引和借閱清單一致()
{
var data = SeedDataGenerator.Generate(Scale.S);
var desk = RentalDesk.FromSeed(data);
var expected = data.Loans.Where(l => !l.IsReturned).ToList();
Assert.Equal(expected.Count, desk.OutstandingCount);
Assert.Equal(expected.Count, desk.AllOutstanding().Count);
Assert.Equal(expected.Count, data.Members.Sum(m => desk.OutstandingLoansOf(m.MemberId).Count));
Assert.All(expected, l => Assert.False(desk.IsAvailable(l.CopyId)));
}
另一條借出與歸還之後索引仍和借閱清單一致是同一件事的動態版:借三本、還一本,然後檢查兩張索引和_loans還對得上。
這種測試第四天沒有,因為那時候沒有第二份資料可以對不上。每多一份為了查得快而存在的資料,就多一條「它會不會說謊」要顧。 第二十天在SQLite上建索引會再遇到同一件事,只是那時候同步維護的是資料庫,不是這段程式碼。
為什麼可以「順手」改?因為店員在今天這個畫面上看不出差別——一百多本書,兩種查法按下去都是立刻回話。快多少,本篇沒有量,所以這裡不寫數字。
那為什麼要改?今天沒有答案,只有一個理由:改建構式的時候順路。明天用數字回答,順便回答第三天問、第四天留到今天的那個問題:一百本很快,兩千本呢。
今天的程式碼放在day06這個tag:
git clone --branch day06 https://github.com/EugeneSu0515/highflyer-ironman.git
cd highflyer-ironman
dotnet test
把這家店放大到第三年的規模——兩千種書、五萬筆借閱,同一串查詢跑一萬次,線性掃描和查表各要多久?同樣這兩種查法,也會在今天這家小店的規模上再量一次。兩個規模都量完,回頭看今天這個「順手」改掉的決定,到底是未雨綢繆,還是過度設計。
這個系列是筆者正在寫的一套教學內容的濃縮版;完整版含每一次量測的原始輸出與更長的決策紀錄,三十天結束後會繼續往下走。
程式碼:https://github.com/EugeneSu0515/highflyer-ironman