<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://kckhchen.com/blog/feed.xml" rel="self" type="application/atom+xml" /><link href="https://kckhchen.com/blog/" rel="alternate" type="text/html" /><updated>2026-02-04T05:13:09+00:00</updated><id>https://kckhchen.com/blog/feed.xml</id><title type="html">Casey Chen</title><subtitle>Archive of my thoughts and notes.</subtitle><author><name></name></author><entry><title type="html">Ruby 和 Python 的 yield 指令</title><link href="https://kckhchen.com/blog/yield-python-vs-ruby/" rel="alternate" type="text/html" title="Ruby 和 Python 的 yield 指令" /><published>2026-02-01T00:00:00+00:00</published><updated>2026-02-01T00:00:00+00:00</updated><id>https://kckhchen.com/blog/yield-python-vs-ruby</id><content type="html" xml:base="https://kckhchen.com/blog/yield-python-vs-ruby/"><![CDATA[<p>在學習 Ruby 和 Python 一段時間後，兩門語言終究會讓你碰到一個神秘的關鍵字：<code class="language-plaintext highlighter-rouge">yield</code>。曾經這個關鍵字困擾了我一段時間，因為他們的用法這麼的相似，卻同時這麼的不同，乍看之下似乎可以將經驗直接套用，但實際操作時卻往往造成意想不到的錯誤。在經過多方閱讀和整理後，我才想寫一篇文章分享這個神奇的指令。</p>

<p>在 Ruby 和 Python 中，<code class="language-plaintext highlighter-rouge">yield</code> 指令都是非常常見且關鍵的指令，並且都有「暫停函式運行」與「讓步給其他函式」的特性。但雖然名字平平都是 <code class="language-plaintext highlighter-rouge">yield</code>，Ruby 和 Python 卻有非常不同的使用情境。這篇文章將會分別介紹其中的相似性以及差異。</p>

<h2 id="ruby-中的-yield">Ruby 中的 yield</h2>

<p>Ruby 的 <code class="language-plaintext highlighter-rouge">yield</code> 可以說是 Ruby 最核心的功能之一，幾乎所有入門課程都會學到。Ruby 的 <code class="language-plaintext highlighter-rouge">yield</code> 用處在於「暫停目前函式，並運行<strong>傳進來的 block</strong>」。直接用例子解釋最清楚：</p>

<div id="secid768e7d" class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">calc</span><span class="p">(</span><span class="n">num1</span><span class="p">,</span> <span class="n">num2</span><span class="p">)</span>
  <span class="nb">puts</span> <span class="s2">"Starts calculation..."</span>
  <span class="k">yield</span> <span class="n">num1</span><span class="p">,</span> <span class="n">num2</span>
  <span class="nb">puts</span> <span class="s2">"Calculation finished!"</span>
<span class="k">end</span>

<span class="n">calc</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span> <span class="p">{</span> <span class="o">|</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="o">|</span> <span class="nb">puts</span> <span class="n">a</span> <span class="o">+</span> <span class="n">b</span> <span class="p">}</span>
</code></pre></div></div>

<p>在上面的範例中 <code class="language-plaintext highlighter-rouge">calc</code> 函數接受三個引數：兩個數字和一個 callback block。這個函數只做一件事情：宣告計算的開始和結束。至於怎麼計算將會交給它 <code class="language-plaintext highlighter-rouge">yield</code> 的 block，並且將前兩個引數也一起傳送過去。</p>

<p>因此，這個程式的運行結果會是：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Starts calculation...
3
Calculation finished!
</code></pre></div></div>

<p>我們也可以改送入別的 block：</p>

<div class="language-rb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">calc</span><span class="p">(</span><span class="mi">5</span><span class="p">,</span> <span class="mi">3</span><span class="p">)</span> <span class="p">{</span> <span class="o">|</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="o">|</span> <span class="nb">puts</span> <span class="n">a</span> <span class="o">-</span> <span class="n">b</span> <span class="p">}</span>
</code></pre></div></div>

<p>得到的結果就會是：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Starts calculation...
2
Calculation finished!
</code></pre></div></div>

<p>因此，中間那行印出什麼數字跟 <code class="language-plaintext highlighter-rouge">calc</code> 函數本身沒有關係，這個函數看到 <code class="language-plaintext highlighter-rouge">yield</code> 時只會停下目前的函式運行，「讓路」給傳送進來的 block。等到那個 block 執行完，<code class="language-plaintext highlighter-rouge">calc</code> 才會繼續把剩下的部分運行完成。</p>

<p><code class="language-plaintext highlighter-rouge">yield</code> 也接受 lambda 作為傳入的 block：</p>
<div class="language-rb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">multiply</span> <span class="o">=</span> <span class="o">-&gt;</span><span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">)</span> <span class="p">{</span> <span class="nb">puts</span> <span class="n">a</span> <span class="o">*</span> <span class="n">b</span> <span class="p">}</span>
<span class="n">calc</span><span class="p">(</span><span class="mi">3</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">multiply</span><span class="p">)</span>

<span class="c1"># returns:</span>
<span class="c1"># Starts calculation...</span>
<span class="c1"># 12</span>
<span class="c1"># Calculation finished!</span>
</code></pre></div></div>

<div class="callout callout-warning">
  <div class="callout-title"><i class="callout-icon" data-lucide="circle-alert"></i><span class="callout-title-text">注意</span></div>
  <p><code class="language-plaintext highlighter-rouge">yield</code> 僅接受 block 作為傳入值，因此當使用 lambda 時，不能直接傳入 <code class="language-plaintext highlighter-rouge">calc(3, 4, multiply)</code>，必須使用 <code class="language-plaintext highlighter-rouge">&amp;</code> 將 lambda 物件中的 block 取出。</p>

</div>
<p>因此，有 <code class="language-plaintext highlighter-rouge">yield</code> 的函式的工作流程大概像這樣：</p>

<blockquote>
  <p>接受參數 → 執行程式 → 暫停程式，將參數（如果有）傳給 callback → 等待 callback 執行 → 繼續執行剩下程式</p>
</blockquote>

<h3 id="ruby-yield-的更多功用">Ruby yield 的更多功用</h3>

<p>Ruby 的 <code class="language-plaintext highlighter-rouge">yield</code> 的好處不只在於它巨大的彈性，也在於 <code class="language-plaintext highlighter-rouge">yield</code> 可以重複多次使用。我們可以來看下面這個範例：</p>

<div class="language-rb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">say_n_times</span><span class="p">(</span><span class="n">n</span><span class="p">)</span>
  <span class="n">n</span><span class="p">.</span><span class="nf">times</span> <span class="k">do</span>
    <span class="k">yield</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="n">say_n_times</span><span class="p">(</span><span class="mi">3</span><span class="p">)</span> <span class="p">{</span> <span class="nb">puts</span> <span class="s2">"This is important."</span> <span class="p">}</span>

<span class="c1"># returns:</span>
<span class="c1"># This is important.</span>
<span class="c1"># This is important.</span>
<span class="c1"># This is important.</span>
</code></pre></div></div>

<p>函數每 <code class="language-plaintext highlighter-rouge">yield</code> 一次，就會運行送進來的 block 一次。</p>

<p>另外，Ruby 也內建了 <code class="language-plaintext highlighter-rouge">#block_given?</code> 方法讓我們可以依據有沒有 block 傳入決定函式運行規則：</p>

<div class="language-rb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">check_block</span>
  <span class="k">if</span> <span class="nb">block_given?</span>
    <span class="n">msg</span> <span class="o">=</span> <span class="k">yield</span> <span class="c1"># 接收 block 的回傳值</span>
    <span class="nb">puts</span> <span class="s2">"Block says </span><span class="si">#{</span><span class="n">msg</span><span class="si">}</span><span class="s2">."</span>
  <span class="k">else</span>
    <span class="nb">puts</span> <span class="s2">"No block given."</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="n">check_block</span> <span class="p">{</span> <span class="s2">"hi"</span> <span class="p">}</span>
<span class="c1">#=&gt; Block says hi.</span>

<span class="n">check_block</span>
<span class="c1">#=&gt; No block given.</span>
</code></pre></div></div>

<div class="callout callout-note">
  <div class="callout-title"><i class="callout-icon" data-lucide="pen"></i><span class="callout-title-text">注意</span></div>
  <p>上方所有函式都不需要特別加入 <code class="language-plaintext highlighter-rouge">&amp;block</code> 作為引數，因為所有 Ruby 函式都預設可以接受一個 block 作為額外參數。如果對這部分有更多興趣，這篇很有啟發性的 <a href="https://medium.com/@jinghua.shih/ruby-%E5%A6%82%E4%BD%95%E7%90%86%E8%A7%A3-ruby-block-2387b74f188b">Medium 文章</a> 推薦給你！</p>
</div>

<h2 id="python-中的-yield">Python 中的 yield</h2>

<p>雖然名字都是 <code class="language-plaintext highlighter-rouge">yield</code>，Python 對於 <code class="language-plaintext highlighter-rouge">yield</code> 卻有完全不同的見解和使用方式。在 Python 中，只要 <code class="language-plaintext highlighter-rouge">yield</code> 出現在函式裡面，就會將該函式變成一個生成器（generator）。碰到 <code class="language-plaintext highlighter-rouge">yield</code> 時，生成器函式會暫停執行，回傳一個值給主函式，並且<strong>等待下一次函式呼叫</strong>。</p>

<p>Python 中的 <code class="language-plaintext highlighter-rouge">yield</code> 最大的用處在於 iterator 當中：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">printer</span><span class="p">():</span>
    <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="n">_iterator</span><span class="p">():</span>
        <span class="k">print</span><span class="p">(</span><span class="n">i</span><span class="p">)</span>

<span class="k">def</span> <span class="nf">_iterator</span><span class="p">():</span>
    <span class="k">yield</span> <span class="mi">1</span>
    <span class="k">yield</span> <span class="mi">2</span>
    <span class="k">yield</span> <span class="mi">3</span>
</code></pre></div></div>

<p>這時如果我們呼叫 <code class="language-plaintext highlighter-rouge">printer()</code>，會得到以下結果：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1
2
3
</code></pre></div></div>

<p>也就是說，<code class="language-plaintext highlighter-rouge">printer</code> 在第一圈 loop 時，會先呼叫 <code class="language-plaintext highlighter-rouge">_iterator</code>，這時 <code class="language-plaintext highlighter-rouge">_iterator</code> 會<strong>回傳 1，並且暫停執行</strong>，等到 <code class="language-plaintext highlighter-rouge">printer</code> 印完，進入第二圈 loop 再次呼叫時，才會繼續執行，回傳 2 後再暫停，直到程式完全執行完畢或是不再被呼叫為止。</p>

<h3 id="python-yield-的好處">Python yield 的好處</h3>

<p>這時許多人可能會有一個疑問：如果我要使用 loop，為什麼不直接 iterate over 一個 list 就好，要這麼麻煩使用一個有 <code class="language-plaintext highlighter-rouge">yield</code> 的函式呢？</p>

<p>主要是因為函式在加上 <code class="language-plaintext highlighter-rouge">yield</code> 之後，就不再是一般的函式，而會變成生成器。生成器在回傳一值之後，就會馬上「忘記」，不會將值儲存在記憶體中（也就是 lazy loading），這對於非常大筆的數據或是很佔空間的檔案非常有利，因為這可以讓我們節省非常多記憶體空間。</p>

<p>舉個例子，如果我想要將我寫的所有部落格文章進行文字處理，我就可以運用生成器：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">save_post</span><span class="p">(</span><span class="n">src_paths</span><span class="p">):</span>
    <span class="c1"># src_path is a list of paths
</span>    <span class="k">for</span> <span class="n">content</span> <span class="ow">in</span> <span class="n">_iter_posts</span><span class="p">(</span><span class="n">src_paths</span><span class="p">):</span>
        <span class="n">processed</span> <span class="o">=</span> <span class="n">process_post</span><span class="p">(</span><span class="n">content</span><span class="p">)</span>
        <span class="c1"># ... goes on to save posts
</span>
<span class="k">def</span> <span class="nf">_iter_posts</span><span class="p">(</span><span class="n">paths</span><span class="p">):</span>
    <span class="k">for</span> <span class="n">src</span> <span class="ow">in</span> <span class="n">paths</span><span class="p">:</span>
        <span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="n">src</span><span class="p">,</span> <span class="s">"r"</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
            <span class="n">content</span> <span class="o">=</span> <span class="n">f</span><span class="p">.</span><span class="n">read</span><span class="p">()</span>
            <span class="k">yield</span> <span class="n">content</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">_iter_posts</code> 每次只會打開一個檔案，讀取裡面的文字內容，並傳回主函式 <code class="language-plaintext highlighter-rouge">save_post</code>。等到主函式處理完這個文章後，在下一次迴圈再次向 <code class="language-plaintext highlighter-rouge">_iter_post</code> 索取下一個值，<code class="language-plaintext highlighter-rouge">_iter_post</code> 就會<strong>關掉並且忘記上一個檔案</strong>，讀取下一個路徑的檔案，並回傳下個檔案的文字內容。也就是說，整個程式碼在運行的期間，同時只會有一個檔案的文字內容存在記憶體中。如果我們使用 list 儲存的話，那就必須先把所有文章內容同時存在記憶體中，讓函式一個一個讀取，造成記憶體壓力，也造成不必要的空間浪費。</p>

<h3 id="更進一步yield-from">更進一步：yield from</h3>

<p>Python 除了 <code class="language-plaintext highlighter-rouge">yield</code> 之外，更進一步提供了 <code class="language-plaintext highlighter-rouge">yield from</code> 的功能，讓我們可以從<strong>另一個生成器讀取值</strong>，並回傳到主函式，有點像是接力傳球的概念：</p>

<div id="secid773608" class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">printer</span><span class="p">():</span>
    <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="n">_iterator</span><span class="p">():</span>
        <span class="k">print</span><span class="p">(</span><span class="n">i</span><span class="p">)</span>

<span class="k">def</span> <span class="nf">_iterator</span><span class="p">():</span>
    <span class="k">yield</span> <span class="k">from</span> <span class="p">[</span><span class="mi">1</span><span class="p">,</span> <span class="mi">3</span><span class="p">,</span> <span class="mi">5</span><span class="p">]</span>
	
<span class="c1"># 上面的方法等價於
# for x in [1, 3, 5]:
#     yield x
</span>
<span class="n">printer</span><span class="p">()</span>

<span class="c1"># returns:
# 1
# 3
# 5
</span></code></pre></div></div>

<p>這裡的 <code class="language-plaintext highlighter-rouge">_iterator</code> 在被呼叫時，會轉而向另一個生成器（也就是 Python 內建給 list <code class="language-plaintext highlighter-rouge">[1, 3, 5]</code> 的生成器）求值，並把值回傳給主函式 <code class="language-plaintext highlighter-rouge">printer</code>。雖然在這個範例中這個用法看起來像是多此一舉，不過在更複雜的使用情境中意外地非常好用。</p>

<h2 id="合併比較">合併比較</h2>

<p>最後我們重新把 Ruby 和 Python 使用 <code class="language-plaintext highlighter-rouge">yield</code> 的情境整理和比較一次：</p>

<ul>
  <li>Ruby 的 <code class="language-plaintext highlighter-rouge">yield</code> 存在於主函式中，會<strong>暫停主函式，讓子函式（block）運行</strong>，結束後繼續執行主函式。</li>
  <li>Python 的 <code class="language-plaintext highlighter-rouge">yield</code> 存在子函式中，會<strong>暫停子函式（generator），回傳值給主函式</strong>，並且直到下次被呼叫才會繼續運行。</li>
</ul>

<p>我們也可以把兩個語言的 <code class="language-plaintext highlighter-rouge">yield</code> 化約成「主動」和「被動」的概念。</p>

<ul>
  <li>Ruby 的 <code class="language-plaintext highlighter-rouge">yield</code> 是主動的：主函式 <code class="language-plaintext highlighter-rouge">yield</code> 傳進來的 block 執行動作，主函式 <code class="language-plaintext highlighter-rouge">yield</code> 幾次，block 就要運行幾次。主控權在有 <code class="language-plaintext highlighter-rouge">yield</code> 的主函式身上。</li>
  <li>Python 的 <code class="language-plaintext highlighter-rouge">yield</code> 是被動的：只要被呼叫，生成器就會 <code class="language-plaintext highlighter-rouge">yield</code> 一個回傳值，但只要不（再）被呼叫，<code class="language-plaintext highlighter-rouge">yield</code> 就不會動作。主控權在呼叫生成器的函式身上。</li>
</ul>

<h3 id="腦力激盪用-python-yield-寫-ruby-邏輯">腦力激盪：用 Python <code class="language-plaintext highlighter-rouge">yield</code> 寫 Ruby 邏輯</h3>

<p>回到一開始我們為 Ruby 寫的<a href="#secid768e7d">範例程式</a>：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">calc</span><span class="p">(</span><span class="n">num1</span><span class="p">,</span> <span class="n">num2</span><span class="p">)</span>
  <span class="nb">puts</span> <span class="s2">"Starts calculation..."</span>
  <span class="k">yield</span> <span class="n">num1</span><span class="p">,</span> <span class="n">num2</span>
  <span class="nb">puts</span> <span class="s2">"Calculation finished!"</span>
<span class="k">end</span>

<span class="n">calc</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span> <span class="p">{</span> <span class="o">|</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="o">|</span> <span class="nb">puts</span> <span class="n">a</span> <span class="o">+</span> <span class="n">b</span> <span class="p">}</span>
</code></pre></div></div>

<p>我們能不能用 Python 的 <code class="language-plaintext highlighter-rouge">yield</code> 寫出這樣的邏輯？答案是可以，我們可以讓加法變成主函式，並讓 <code class="language-plaintext highlighter-rouge">calc</code> 成為被呼叫的子函式：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">calc</span><span class="p">(</span><span class="n">num1</span><span class="p">,</span> <span class="n">num2</span><span class="p">):</span>
    <span class="k">print</span><span class="p">(</span><span class="s">"Starts calculation..."</span><span class="p">)</span>
    <span class="k">yield</span> <span class="n">num1</span><span class="p">,</span> <span class="n">num2</span>
    <span class="k">print</span><span class="p">(</span><span class="s">"Calculation finished!"</span><span class="p">)</span>


<span class="k">def</span> <span class="nf">add</span><span class="p">(</span><span class="n">num1</span><span class="p">,</span> <span class="n">num2</span><span class="p">):</span>
    <span class="k">for</span> <span class="n">num1</span><span class="p">,</span> <span class="n">num2</span> <span class="ow">in</span> <span class="n">calc</span><span class="p">(</span><span class="n">num1</span><span class="p">,</span> <span class="n">num2</span><span class="p">):</span>
        <span class="k">print</span><span class="p">(</span><span class="n">num1</span> <span class="o">+</span> <span class="n">num2</span><span class="p">)</span>

<span class="n">add</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span>
</code></pre></div></div>

<p>這時的程式碼工作流程長什麼樣子呢？</p>

<blockquote>
  <p><code class="language-plaintext highlighter-rouge">add(1, 2)</code> 被呼叫 → 在迴圈呼叫 <code class="language-plaintext highlighter-rouge">calc(1, 2)</code> → <code class="language-plaintext highlighter-rouge">calc</code> 印出 “Starts calculation…” 並回傳 1, 2 後暫停運作 → <code class="language-plaintext highlighter-rouge">add</code> 印出加法結果，進入下一個迴圈 → <code class="language-plaintext highlighter-rouge">calc</code> 再次被呼叫，繼續執行，印出 “Calculation finished!” 後枯竭（沒有其他東西可以 yield 了）→ <code class="language-plaintext highlighter-rouge">add</code> 得知 <code class="language-plaintext highlighter-rouge">calc</code> 枯竭，跳出迴圈結束執行。</p>
</blockquote>

<p>非常複雜吧！不過重點不是中間到底發生了什麼事，這裡舉例只是為了更清楚說明 <code class="language-plaintext highlighter-rouge">yield</code> 在 Python 中更像是屬於子函式的功能而已。</p>

<div class="callout callout-error">
  <div class="callout-title"><i class="callout-icon" data-lucide="zap"></i><span class="callout-title-text">警告</span></div>
  <p>以上的程式碼僅供示例和比較用途，這並非 Python 的 <code class="language-plaintext highlighter-rouge">yield</code> 的設計邏輯，強硬逼迫 Python 這樣運作只會讓程式碼變得非常難讀。另外，其實 Python 有提供 <code class="language-plaintext highlighter-rouge">@contextmanager</code> 裝飾子讓整個函式更簡潔、更像 Ruby，不過已經超出本次討論範圍。</p>
</div>
<h3 id="腦力激盪用-ruby-yield-寫-python-邏輯">腦力激盪：用 Ruby yield 寫 Python 邏輯</h3>

<p>反過來說，能不能用 Ruby 的 yield 寫出上面的<a href="#secid773608">這個 Python 邏輯</a>？</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">printer</span><span class="p">():</span>
    <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="n">_iterator</span><span class="p">():</span>
        <span class="k">print</span><span class="p">(</span><span class="n">i</span><span class="p">)</span>

<span class="k">def</span> <span class="nf">_iterator</span><span class="p">():</span>
    <span class="k">yield</span> <span class="k">from</span> <span class="p">[</span><span class="mi">1</span><span class="p">,</span> <span class="mi">3</span><span class="p">,</span> <span class="mi">5</span><span class="p">]</span>
</code></pre></div></div>

<p>當然也可以，只要抓住核心重點，讓主函式 <code class="language-plaintext highlighter-rouge">printer</code> yield 給子函數（block）就可以了：</p>

<div class="language-rb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">printer</span><span class="p">(</span><span class="n">list</span><span class="p">)</span>
  <span class="n">list</span><span class="p">.</span><span class="nf">each</span> <span class="p">{</span> <span class="o">|</span><span class="n">item</span><span class="o">|</span> <span class="k">yield</span> <span class="n">item</span> <span class="p">}</span>
<span class="k">end</span>

<span class="n">printer</span><span class="p">([</span><span class="mi">1</span><span class="p">,</span> <span class="mi">3</span><span class="p">,</span> <span class="mi">5</span><span class="p">])</span> <span class="p">{</span> <span class="o">|</span><span class="n">i</span><span class="o">|</span>  <span class="nb">puts</span> <span class="n">i</span> <span class="p">}</span>
</code></pre></div></div>

<p>這裡的程式工作流程變成以下：</p>

<blockquote>
  <p><code class="language-plaintext highlighter-rouge">printer([1, 2, 3])</code> 被呼叫 → 透過 <code class="language-plaintext highlighter-rouge">#each</code> 方法每次 yield 一個值給 <code class="language-plaintext highlighter-rouge">{ puts i }</code> 的 block → block 執行完畢，控制權交還給 <code class="language-plaintext highlighter-rouge">printer</code> → 進入下一次迴圈 → … → list 枯竭，程式結束</p>
</blockquote>

<div class="callout callout-error">
  <div class="callout-title"><i class="callout-icon" data-lucide="zap"></i><span class="callout-title-text">警告</span></div>
  <p>這在 Ruby 中一樣是很不直覺且曲折的寫法，僅供展示比較。而且 <code class="language-plaintext highlighter-rouge">#each</code> 方法已經內建使用 <code class="language-plaintext highlighter-rouge">yield</code> 了（有沒有發現 <code class="language-plaintext highlighter-rouge">#each</code> 的使用方式也是傳入一個 block？）。另外，如果想在 Ruby 中實現 lazy loading，必須額外調用 <code class="language-plaintext highlighter-rouge">#lazy</code> 方法（Ruby 2.0+）或是使用 <code class="language-plaintext highlighter-rouge">Enumerator</code>，但這不在本次的討論範圍內。</p>
</div>
<h2 id="結論">結論</h2>

<p>這篇文章還有更多的相似性和差異沒有講到，不過為了不模糊文章焦點，就先略過了。</p>

<p>繞了一大圈，希望沒有讓大家暈頭轉向。這篇文章比較適合熟悉其中一門語言，想透過邏輯類比學習另一門語言的人閱讀。就算平常沒有太多使用到 <code class="language-plaintext highlighter-rouge">yield</code> 的場景，希望這篇文章也可以給你一點啟發，或是至少在腦力激盪的環節玩得開心！</p>

<!-- Obsidian Callout Styles - Generated by Obsidian2Jekyll -->
<style>
  .callout {
    padding: 10px 24px 2px;
    margin: 1.5em 0;
    border-radius: 4px;
    background-color: rgba(200, 200, 200, 0.2);
    --accent-clr: #9c9c9c;
  }

  .callout-icon {
    width: 1rem;
    margin-right: 0.5rem;
  }

  .callout-title {
    width: 80%;
    font-weight: bold;
    margin-bottom: 8px;
    display: flex;
    align-items: center;
    color: var(--accent-clr);
  }

  .callout-info,
  .callout-todo,
  .callout-note {
    background-color: rgba(160, 200, 255, 0.2);
    --accent-clr: #5d92ee;
    border-left-color: var(--accent-clr);
  }

  .callout-abstract,
  .callout-summary,
  .callout-tldr,
  .callout-tip,
  .callout-hint,
  .callout-important {
    background-color: rgba(155, 255, 235, 0.2);
    --accent-clr: #53d2d0;
    border-left-color: var(--accent-clr);
  }

  .callout-success,
  .callout-check,
  .callout-done {
    background-color: rgba(140, 255, 180, 0.2);
    --accent-clr: #37b94e;
    border-left-color: var(--accent-clr);
  }

  .callout-warning,
  .callout-question,
  .callout-help,
  .callout-faq,
  .callout-caution,
  .callout-attention {
    background-color: rgba(245, 190, 160, 0.2);
    --accent-clr: #ec7501;
    border-left-color: var(--accent-clr);
  }

  .callout-danger,
  .callout-error,
  .callout-bug,
  .callout-fail,
  .callout-failure,
  .callout-missing {
    background-color: rgba(255, 200, 205, 0.2);
    --accent-clr: #ea3d51;
    border-left-color: var(--accent-clr);
  }

  .callout-example {
    background-color: rgba(215, 195, 250, 0.2);
    --accent-clr: #a181ff;
    border-left-color: var(--accent-clr);
  }

  .callout-quote,
  .callout-cite {
    background-color: rgba(220, 220, 220, 0.2);
    --accent-clr: #ababab;
    border-left-color: var(--accent-clr);
  }

  details > summary:first-of-type::after {
    content: '▶';
    display: inline-block;
    position: relative;
    right: -1em;
    transition: transform 0.2s ease;
  }

  details[open] > summary:first-of-type::after {
    transform: rotate(90deg);
  }
</style>

<!-- Lucide CDN -->
<script src="https://unpkg.com/lucide@latest"></script>

<script>
  lucide.createIcons({
    attrs: {
      'stroke-width': 2.5,
      stroke: 'currentColor',
    },
  });
</script>]]></content><author><name></name></author><category term="ruby" /><category term="python" /><summary type="html"><![CDATA[在學習 Ruby 和 Python 一段時間後，兩門語言終究會讓你碰到一個神秘的關鍵字：yield。曾經這個關鍵字困擾了我一段時間，因為他們的用法這麼的相似，卻同時這麼的不同，乍看之下似乎可以將經驗直接套用，但實際操作時卻往往造成意想不到的錯誤。在經過多方閱讀和整理後，我才想寫一篇文章分享這個神奇的指令。]]></summary></entry><entry><title type="html">修改 Jekyll minima 主題的三種方式</title><link href="https://kckhchen.com/blog/customize-minima/" rel="alternate" type="text/html" title="修改 Jekyll minima 主題的三種方式" /><published>2026-01-28T00:00:00+00:00</published><updated>2026-01-28T00:00:00+00:00</updated><id>https://kckhchen.com/blog/customize-minima</id><content type="html" xml:base="https://kckhchen.com/blog/customize-minima/"><![CDATA[<p><a href="https://jekyllrb.com/">Jekyll</a> 作為 GitHub 內建的 Static Site Generator (SSG)，應該是不少人寫技術型 blog 的入門選擇。另外，Jekyll 的主題 <a href="https://github.com/jekyll/minima">minima</a> 因為簡單、輕量、快速、易上手，又是 Jekyll 預設主題的特性，也使很多人選擇的風格樣式。</p>

<p>minima 簡單的好處還有一個，也就是修改起來非常容易，不容易碰到 conflict，算是非常好客製化。修改 minima 主題主要有三種方法，從淺到深、從簡單到複雜分別是：</p>

<ol>
  <li><a href="#修改-sass-變數">修改 sass 變數</a></li>
  <li><a href="#覆寫-css-樣式">覆寫 css 樣式</a></li>
  <li><a href="#修改-source-code">修改 source code</a></li>
</ol>

<h2 id="修改-sass-變數">修改 sass 變數</h2>

<p>minima 提供不少方便的 sass 變數讓我們可以快速覆寫。</p>

<p>我們首先要在根目錄下創建 <code class="language-plaintext highlighter-rouge">assets/main.scss</code> 的檔案，並且加上<strong>空白的 frontmatter</strong>，就可以在這裡重新給 minima 提供的 sass 變數重新賦值。並且，在定義完 sass 變數<strong>之後</strong>再 import minima 主題進來：</p>

<div class="language-scss highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// main.scss</span>
<span class="na">---</span><span class="err">
</span><span class="na">---</span><span class="err">

$</span><span class="na">base-font-size</span><span class="p">:</span> <span class="m">18px</span><span class="p">;</span> <span class="c1">// 重新設定字體大小</span>

<span class="k">@import</span> <span class="s1">'minima'</span><span class="p">;</span> <span class="c1">// 之後再 import minima 主題</span>
</code></pre></div></div>

<p>minima 提供了一系列 sass 變數可以修改，以下列出最通用的 minima 2.5.1 版本提供的所有變數（包含預設數值），有興趣可以去<a href="https://github.com/jekyll/minima/blob/v2.5.1/_sass/minima.scss">這裡</a>看 source code。</p>

<div class="language-scss highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$base-font-family</span><span class="p">:</span> <span class="o">-</span><span class="n">apple-system</span><span class="p">;</span> <span class="c1">// 字體樣式</span>
<span class="nv">$base-font-size</span><span class="p">:</span>   <span class="m">16px</span><span class="p">;</span> <span class="c1">// 基本字體大小</span>
<span class="nv">$base-font-weight</span><span class="p">:</span> <span class="m">400</span><span class="p">;</span> <span class="c1">// 基本字重</span>
<span class="nv">$small-font-size</span><span class="p">:</span>  <span class="nv">$base-font-size</span> <span class="o">*</span> <span class="m">0</span><span class="mi">.875</span><span class="p">;</span> <span class="c1">// 小字字體大小</span>
<span class="nv">$base-line-height</span><span class="p">:</span> <span class="m">1</span><span class="mi">.5</span><span class="p">;</span> <span class="c1">// 基本行高，單位 em</span>

<span class="nv">$spacing-unit</span><span class="p">:</span>     <span class="m">30px</span><span class="p">;</span> <span class="c1">// 基本空白單位，用於控制 padding, margin 等</span>

<span class="nv">$text-color</span><span class="p">:</span>       <span class="mh">#111</span><span class="p">;</span> <span class="c1">// 字體顏色，預設深灰</span>
<span class="nv">$background-color</span><span class="p">:</span> <span class="mh">#fdfdfd</span><span class="p">;</span> <span class="c1">// 背景顏色，預設白色</span>
<span class="nv">$brand-color</span><span class="p">:</span>      <span class="mh">#2a7ae2</span><span class="p">;</span> <span class="c1">// 連結顏色，預設藍色</span>

<span class="nv">$grey-color</span><span class="p">:</span>       <span class="mh">#828282</span><span class="p">;</span> <span class="c1">// 基本灰色，主要控制 blockquote 和 table 背景色</span>
<span class="nv">$grey-color-light</span><span class="p">:</span> <span class="nf">lighten</span><span class="p">(</span><span class="nv">$grey-color</span><span class="o">,</span> <span class="m">40%</span><span class="p">);</span> <span class="c1">// blockquote, code block, table 的 border 顏色</span>
<span class="nv">$grey-color-dark</span><span class="p">:</span>  <span class="nf">darken</span><span class="p">(</span><span class="nv">$grey-color</span><span class="o">,</span> <span class="m">25%</span><span class="p">);</span> <span class="c1">// 網站 title 上面那條灰線</span>

<span class="nv">$table-text-align</span><span class="p">:</span> <span class="nb">left</span><span class="p">;</span> <span class="c1">// table 的對齊方法</span>

<span class="nv">$content-width</span><span class="p">:</span>    <span class="m">800px</span><span class="p">;</span> <span class="c1">// 文字空間的 max-width</span>

<span class="c1">// 最後兩個用在 media-query 上，如果螢幕大小低於設定下面，minima 會變成「好讀版」</span>
<span class="nv">$on-palm</span><span class="p">:</span>          <span class="m">600px</span><span class="p">;</span> <span class="c1">// 轉成手機版的螢幕大小</span>
<span class="nv">$on-laptop</span><span class="p">:</span>        <span class="m">800px</span><span class="p">;</span> <span class="c1">// 轉成筆電版的螢幕大小</span>
</code></pre></div></div>

<div class="callout callout-warning">
  <div class="callout-title"><i class="callout-icon" data-lucide="circle-alert"></i><span class="callout-title-text">注意</span></div>
  <p>minima 不同版本可能會提供不同的變數，特別是最新版的 minima 3.0 以上差異可能更大。GitHub 預設是 2.5.1，但如果你有自己調整版本，請自行透過 <code class="language-plaintext highlighter-rouge">bundle show minima</code> 前往主題資料夾查詢。</p>
</div>

<h2 id="覆寫-css-樣式">覆寫 css 樣式</h2>

<p>如果上面這些變數不能滿足你的需求（這很常見），我們還可以在同一個檔案 <code class="language-plaintext highlighter-rouge">assets/main.scss</code> 中覆寫 css 樣式。這就會要求我們要有一些基本的 css 知識。不過請注意，css 樣式請寫在 <code class="language-plaintext highlighter-rouge">@import 'minima';</code> 指令<strong>之後</strong>才能夠覆蓋預設樣式。</p>

<p>例如，我不喜歡 minima 原生的文章列表顯示方式：</p>

<p><img src="/blog/assets/images/minima-default-page.png" alt="" width="500" /></p>

<p>透過 inspect element，我發現那些文章標題的 class 是 <code class="language-plaintext highlighter-rouge">post-link</code>。那我就可以這樣覆蓋樣式：</p>

<div class="language-scss highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">---</span>
<span class="nt">---</span>

<span class="o">@</span><span class="nt">import</span> <span class="s1">'minima'</span><span class="p">;</span>

<span class="nc">.post-link</span> <span class="p">{</span>
  <span class="nl">color</span><span class="p">:</span> <span class="mh">#111111</span><span class="p">;</span> <span class="c1">// 改成黑色</span>
  <span class="nl">font-weight</span><span class="p">:</span> <span class="nb">bold</span><span class="p">;</span> <span class="c1">// 加粗</span>
  <span class="nl">font-size</span><span class="p">:</span> <span class="m">24px</span><span class="p">;</span> <span class="c1">// 加大</span>
<span class="p">}</span>
</code></pre></div></div>

<p>如果發現 css 樣式有 specificity 衝突，我們可以研究如何加強我們 selector 的 specificity，或是直接暴力用 <code class="language-plaintext highlighter-rouge">!important</code> 覆蓋（雖然不太建議），之後就可以得到像這樣的結果：</p>

<p><img src="/blog/assets/images/customized-minima-theme.png" alt="" width="500" /></p>

<div class="callout callout-note">
  <div class="callout-title"><i class="callout-icon" data-lucide="pen"></i><span class="callout-title-text">提醒</span></div>
  <p>請注意 css 樣式一定要寫在 <code class="language-plaintext highlighter-rouge">@import 'minima';</code> 之後，但 sass 變數要定義在 <code class="language-plaintext highlighter-rouge">@import 'minima';</code> 之前。</p>
</div>
<h3 id="額外補充利用-import-模組化-css">額外補充：利用 <code class="language-plaintext highlighter-rouge">@import</code> 模組化 css</h3>

<p>由於 minima 主題使用 scss，我們也可以跟著好好利用 scss 的 <code class="language-plaintext highlighter-rouge">@import</code> 優勢讓我們的 <code class="language-plaintext highlighter-rouge">main.scss</code> 更簡潔。這在我們客製化內容越來越多時會開始變得很有用。</p>

<p>與其直接編輯 <code class="language-plaintext highlighter-rouge">main.scss</code>，我們可以建立一個 <code class="language-plaintext highlighter-rouge">_sass/</code> 資料夾在根目錄（這是 minima 預設讀取 scss 的地方），在那裡建立 scss 檔案。假設我建立了 <code class="language-plaintext highlighter-rouge">_sass/custom_style.scss</code>，我就可以回到 <code class="language-plaintext highlighter-rouge">main.scss</code> 並且輸入：</p>

<div class="language-scss highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">@import</span> <span class="s1">'minima'</span><span class="p">;</span>

<span class="k">@import</span> <span class="s1">'custom_style'</span><span class="p">;</span> <span class="c1">// 提供檔名就好，不需要路徑和副檔名</span>
</code></pre></div></div>

<p>到時候整個 scss 檔案的內容就會被 import 進來了～要注意的是，如果遇到樣式衝突，<strong>比較晚 import 的檔案會覆蓋前面的樣式</strong>，所以要特別注意 import 的順序。</p>

<h2 id="修改-source-code">修改 source code</h2>

<p>以上兩種方法應該可以解決 80% 的風格問題，但如果以上兩種都不能滿足你，我們還可以直接從源頭<strong>覆蓋整個 minima 設定檔</strong>。首先，我們要先找到電腦上的 minima 主題在哪裡，先使用以下指令：</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>bundle show minima 
<span class="c"># returns /Users/me/.rbenv/versions/3.4.6/lib/ruby/gems/3.4.0/gems/minima-2.5.1</span>
</code></pre></div></div>

<p>這會顯示我們現在使用的 minima 原始碼位置。於是我們就可以進去路徑，找到我們真正想改的元素。</p>

<p>舉例來說，預設文章頁面的日期會附在標題之下：</p>

<p><img src="/blog/assets/images/minima-title-example.png" alt="" width="500" /></p>

<p>如果我希望日期出現在標題之上，我就可以到 minima theme 路徑中找到控制 post 頁面的檔案 <code class="language-plaintext highlighter-rouge">_layouts/post.html</code>。接著，我要<strong>複製整個檔案，並且在我的網頁資料夾複製一樣的路徑</strong>，也就是說，我的網頁資料夾根目錄會也要有一個 <code class="language-plaintext highlighter-rouge">_layouts/post.html</code>，並且要貼上 minima theme 的內容，之後再對這個檔案做修改。</p>

<div class="callout callout-warning">
  <div class="callout-title"><i class="callout-icon" data-lucide="circle-alert"></i><span class="callout-title-text">注意</span></div>
  <p>請不要直接修改 minima theme 資料夾中的文件。這會造成三個問題：</p>
  <ol>
    <li>所有的變化會作用在<strong>你所有使用這個 theme 的網站上</strong>，我們只希望 local 覆寫</li>
    <li>如果我們更新 minima 的 gem，原本的修改就會被更新覆蓋掉</li>
    <li>如果你用 GitHub Pages 部署，你無法更改他們原生的 minima 路徑檔案，只能覆蓋</li>
  </ol>
</div>
<p>我在 <code class="language-plaintext highlighter-rouge">post.html</code> 裡面找到這個：</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;h1</span> <span class="na">class=</span><span class="s">"post-title p-name"</span> <span class="na">itemprop=</span><span class="s">"name headline"</span><span class="nt">&gt;</span>
  {{ page.title | escape }}
<span class="nt">&lt;/h1&gt;</span>

<span class="nt">&lt;p</span> <span class="na">class=</span><span class="s">"post-meta"</span><span class="nt">&gt;</span>
  ...
  {{ page.date | date: date_format }}
  ...
<span class="nt">&lt;/p&gt;</span>
</code></pre></div></div>

<p>只要我把 <code class="language-plaintext highlighter-rouge">h1</code> 標籤移動到 <code class="language-plaintext highlighter-rouge">post-meta</code> 的下面：</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;p</span> <span class="na">class=</span><span class="s">"post-meta"</span><span class="nt">&gt;</span>
  ...
  {{ page.date | date: date_format }}
  ...
<span class="nt">&lt;/p&gt;</span>

<span class="nt">&lt;h1</span> <span class="na">class=</span><span class="s">"post-title p-name"</span> <span class="na">itemprop=</span><span class="s">"name headline"</span><span class="nt">&gt;</span>
  {{ page.title | escape }}
<span class="nt">&lt;/h1&gt;</span>
</code></pre></div></div>

<p>再重新啟動網站，就會看到標題改好了：</p>

<p><img src="/blog/assets/images/minima-title-after.png" alt="" width="500" /></p>

<p>但是使用這種方法時請務必小心，因為 minima theme 路徑裡面許多檔案可能互相 import，如果沒有修改好可能會出現不預期的錯誤，請謹慎使用。</p>

<h2 id="最後小提醒">最後小提醒</h2>

<p>如果你也是用 Visual Code Studio 編輯 scss 檔案的話，有很大的機率你會看到這個錯誤出現在 scss 檔案第一行：</p>

<p><img src="/blog/assets/images/vsc-scss-error.png" alt="" /></p>

<p>這其實是因為 Jekyll 的 frontmatter（最上面那兩行<code class="language-plaintext highlighter-rouge">---</code>）和 scss 的規定衝突了，因為 frontmatter 不是 scss 檔案認識的字串。不過不用擔心，這個錯誤可以直接忽視，你的 Jekyll 網站還是可以正常跑起來。</p>

<p>但如果你跟我一樣，看到錯誤就渾身不舒服，不喜歡 VSC 整天把檔案標成紅色，有一個很簡單的解決辦法，在 fontmatter 之後<strong>隨便定義一個 css 樣式</strong>，再寫設定就好了：</p>

<div class="language-scss highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">---</span>
<span class="nt">---</span>

<span class="nt">body</span> <span class="p">{</span>
  <span class="nl">margin</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span>
<span class="p">}</span>
<span class="c1">// 先隨便寫一個 css 樣式</span>

<span class="nv">$base-font-size</span><span class="p">:</span> <span class="m">18px</span><span class="p">;</span> <span class="c1">// 再定義或 import minima</span>
</code></pre></div></div>

<p>至於要寫什麼 css 樣式其實沒有很重要，因為後面 import minima 的時候這個樣式就會被覆蓋掉了～他只是一個阻止 VSC 報錯的小裝飾而已。</p>

<h2 id="結語">結語</h2>

<p>以上就是本篇文章的所有內容，希望大家都能夠好好體驗 minima 送給我們的極大自由，將 blog 客製化成最有個人特色的樣子，Happy Blogging!</p>

<style>
img {
  border: 1px solid black;
}
</style>

<!-- Obsidian Callout Styles - Generated by Obsidian2Jekyll -->
<style>
  .callout {
    padding: 10px 24px 2px;
    margin: 1.5em 0;
    border-radius: 4px;
    background-color: rgba(200, 200, 200, 0.2);
    --accent-clr: #9c9c9c;
  }

  .callout-icon {
    width: 1rem;
    margin-right: 0.5rem;
  }

  .callout-title {
    width: 80%;
    font-weight: bold;
    margin-bottom: 8px;
    display: flex;
    align-items: center;
    color: var(--accent-clr);
  }

  .callout-info,
  .callout-todo,
  .callout-note {
    background-color: rgba(160, 200, 255, 0.2);
    --accent-clr: #5d92ee;
    border-left-color: var(--accent-clr);
  }

  .callout-abstract,
  .callout-summary,
  .callout-tldr,
  .callout-tip,
  .callout-hint,
  .callout-important {
    background-color: rgba(155, 255, 235, 0.2);
    --accent-clr: #53d2d0;
    border-left-color: var(--accent-clr);
  }

  .callout-success,
  .callout-check,
  .callout-done {
    background-color: rgba(140, 255, 180, 0.2);
    --accent-clr: #37b94e;
    border-left-color: var(--accent-clr);
  }

  .callout-warning,
  .callout-question,
  .callout-help,
  .callout-faq,
  .callout-caution,
  .callout-attention {
    background-color: rgba(245, 190, 160, 0.2);
    --accent-clr: #ec7501;
    border-left-color: var(--accent-clr);
  }

  .callout-danger,
  .callout-error,
  .callout-bug,
  .callout-fail,
  .callout-failure,
  .callout-missing {
    background-color: rgba(255, 200, 205, 0.2);
    --accent-clr: #ea3d51;
    border-left-color: var(--accent-clr);
  }

  .callout-example {
    background-color: rgba(215, 195, 250, 0.2);
    --accent-clr: #a181ff;
    border-left-color: var(--accent-clr);
  }

  .callout-quote,
  .callout-cite {
    background-color: rgba(220, 220, 220, 0.2);
    --accent-clr: #ababab;
    border-left-color: var(--accent-clr);
  }

  details > summary:first-of-type::after {
    content: '▶';
    display: inline-block;
    position: relative;
    right: -1em;
    transition: transform 0.2s ease;
  }

  details[open] > summary:first-of-type::after {
    transform: rotate(90deg);
  }
</style>

<!-- Lucide CDN -->
<script src="https://unpkg.com/lucide@latest"></script>

<script>
  lucide.createIcons({
    attrs: {
      'stroke-width': 2.5,
      stroke: 'currentColor',
    },
  });
</script>]]></content><author><name></name></author><summary type="html"><![CDATA[Jekyll 作為 GitHub 內建的 Static Site Generator (SSG)，應該是不少人寫技術型 blog 的入門選擇。另外，Jekyll 的主題 minima 因為簡單、輕量、快速、易上手，又是 Jekyll 預設主題的特性，也使很多人選擇的風格樣式。]]></summary></entry><entry><title type="html">Ruby 的 Symbol 是什麼？</title><link href="https://kckhchen.com/blog/ruby-symbol/" rel="alternate" type="text/html" title="Ruby 的 Symbol 是什麼？" /><published>2026-01-26T00:00:00+00:00</published><updated>2026-01-26T00:00:00+00:00</updated><id>https://kckhchen.com/blog/ruby-symbol</id><content type="html" xml:base="https://kckhchen.com/blog/ruby-symbol/"><![CDATA[<p>在 Ruby 中，有兩個長相相似但功能完全不同的物件：Symbol 以及 String（字串）。Ruby 是少數有 Symbol 物件的語言，而它的用法也很有趣。</p>

<p>在 Ruby 中，Symbol 長得像是一個由冒號開頭的變數，例如 <code class="language-plaintext highlighter-rouge">:email</code> 或是 <code class="language-plaintext highlighter-rouge">:username</code>。跟字串不同，Ruby 以及幾乎所有程式語言中，字串的長相都是由單引號或是雙引號包覆：<code class="language-plaintext highlighter-rouge">"username"</code> 或是 <code class="language-plaintext highlighter-rouge">'a cool string'</code>。</p>

<h2 id="可變性和記憶體位置">可變性和記憶體位置</h2>

<p>這兩個物件的最大差異在於可變性（mutability）：Symbol 是不可變的（immutable），而字串是可變的（mutable）。我們可以直接用一個例子示範：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;</span> <span class="n">str</span> <span class="o">=</span> <span class="s2">"abc"</span>
<span class="c1">#=&gt; "abc"</span>

<span class="c1"># 更改部分字串</span>
<span class="o">&gt;</span> <span class="n">str</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="o">=</span> <span class="s2">"b"</span>
<span class="o">&gt;</span> <span class="nb">puts</span> <span class="n">str</span>
<span class="c1">#=&gt; "bbc"</span>

<span class="c1"># append 字串</span>
<span class="o">&gt;</span> <span class="n">str</span> <span class="o">&lt;&lt;</span> <span class="s2">"d"</span>
<span class="c1">#=&gt; "bbcd"</span>
</code></pre></div></div>

<p>我們可以輕易修改一個字串的內容。然而，Symbol 不允許這樣的操作：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;</span> <span class="n">sym</span> <span class="o">=</span> <span class="ss">:abc</span>
<span class="c1">#=&gt; :abc</span>

<span class="o">&gt;</span> <span class="n">sym</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="o">=</span> <span class="s2">"b"</span>
<span class="c1">#=&gt; undefined method '[]=' for an instance of Symbol (NoMethodError)</span>

<span class="o">&gt;</span> <span class="n">sym</span> <span class="o">&lt;&lt;</span> <span class="s2">"a"</span>
<span class="c1">#=&gt; undefined method '&lt;&lt;' for an instance of Symbol (NoMethodError)</span>
</code></pre></div></div>

<p>Ruby 並沒有給予 Symbol 更改長相的功能。Symbol 的不可變性給了它一個巨大的優勢：只要是同一個 Symbol，在記憶體中就只會有一個位置。相反的，每個字串，即便內容一樣，在記憶體中是<strong>不同的東西</strong>。我們可以簡單進行下面的實驗：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;</span> <span class="n">str1</span> <span class="o">=</span> <span class="s2">"abc"</span>
<span class="o">&gt;</span> <span class="n">str2</span> <span class="o">=</span> <span class="s2">"abc"</span>
<span class="o">&gt;</span> <span class="n">str3</span> <span class="o">=</span> <span class="s2">"abc"</span>

<span class="o">&gt;</span> <span class="n">str1</span><span class="p">.</span><span class="nf">object_id</span>
<span class="c1">#=&gt; 199184</span>
<span class="o">&gt;</span> <span class="n">str2</span><span class="p">.</span><span class="nf">object_id</span>
<span class="c1">#=&gt; 201824</span>
<span class="o">&gt;</span> <span class="n">str3</span><span class="p">.</span><span class="nf">object_id</span>
<span class="c1">#=&gt; 204464</span>
</code></pre></div></div>

<p>這種特性使得字串容易讓我們的記憶體充斥一堆重複物件。但 Symbol 呢？</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;</span> <span class="n">sym1</span> <span class="o">=</span> <span class="ss">:abc</span>
<span class="o">&gt;</span> <span class="n">sym2</span> <span class="o">=</span> <span class="ss">:abc</span>
<span class="o">&gt;</span> <span class="n">sym3</span> <span class="o">=</span> <span class="ss">:abc</span>

<span class="o">&gt;</span> <span class="n">sym1</span><span class="p">.</span><span class="nf">object_id</span>
<span class="c1">#=&gt; 51294476</span>
<span class="o">&gt;</span> <span class="n">sym2</span><span class="p">.</span><span class="nf">object_id</span>
<span class="c1">#=&gt; 51294476</span>
<span class="o">&gt;</span> <span class="n">sym3</span><span class="p">.</span><span class="nf">object_id</span>
<span class="c1">#=&gt; 51294476</span>
</code></pre></div></div>

<p>我們會發現無論 assign 幾次，<code class="language-plaintext highlighter-rouge">object_id</code> 都是一樣的。Symbol 物件是獨一無二的，只要有兩個變數儲存的是同一個 Symbol，它們就會指向記憶體的同個位置。</p>

<p>這兩個特性（不可變性和獨一無二）使得 Symbol 非常適合用在儲存物件的「標籤」，而可變且每次儲存位置都不同的字串適合用在儲存物件的「內容」上。</p>

<p>一個最常見的例子就是在 Hash 當中，使用 Symbol 當作 key，並使用字串作為 value：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;</span> <span class="nb">hash</span> <span class="o">=</span> <span class="p">{</span> <span class="ss">:username</span> <span class="o">=&gt;</span> <span class="s2">"awesome_user"</span> <span class="p">}</span>
</code></pre></div></div>

<p>因為 Symbol 不可變且位置唯一的特性，使得 <code class="language-plaintext highlighter-rouge">:username</code> 本身不會被意外修改，且查找速度比字串快，因為比起字串的逐字比對，Symbol 可以直接用整數 ID 比對，非常適合作為 key 使用。事實上，由於這個 Hash 的使用方法實在太常用，使得 Ruby 提供了一種更簡便、更直覺易懂的寫法：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;</span> <span class="nb">hash</span> <span class="o">=</span> <span class="p">{</span> <span class="ss">username: </span><span class="s2">"awesome_user"</span> <span class="p">}</span>
<span class="c1"># 這跟前方的 hash = { :username =&gt; "awesome_user" } 一模一樣</span>
</code></pre></div></div>

<p>這樣的寫法更易懂、且跟大家常見的 JSON 很像，也不用再使用不直覺的 rocket notation <code class="language-plaintext highlighter-rouge">=&gt;</code>。在這裡 <code class="language-plaintext highlighter-rouge">username</code> 看起來雖然像是變數，但他卻是一個 Symbol。</p>

<h2 id="和-python-字串比較">和 Python 字串比較</h2>

<p>聽起來 Symbol 好像是某種 Ruby 特有的奇特物件，不過並非如此。因為 Python 中的 String 正好具有 Ruby Symbol 的功能！我們來看一下 Python 字串物件的特性：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;&gt;&gt;</span> <span class="n">str1</span> <span class="o">=</span> <span class="s">"abc"</span>
<span class="o">&gt;&gt;&gt;</span> <span class="n">str2</span> <span class="o">=</span> <span class="s">"abc"</span>

<span class="o">&gt;&gt;&gt;</span> <span class="nb">id</span><span class="p">(</span><span class="n">str1</span><span class="p">)</span>
<span class="c1">#4338663984
</span><span class="o">&gt;&gt;&gt;</span> <span class="nb">id</span><span class="p">(</span><span class="n">str2</span><span class="p">)</span>
<span class="c1">#4338663984
</span></code></pre></div></div>

<p>沒錯，Python 能夠判定某個字串物件是不是之前已經 assign 過，並且聰明地將新的變數指向記憶體中的同一個位置！並且，Python 的字串就像 Ruby 的 Symbol，是不可變的：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;&gt;&gt;</span> <span class="n">str1</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="o">=</span> <span class="s">"b"</span>
<span class="c1">#'str' object does not support item assignment
</span></code></pre></div></div>

<p>因此 Ruby 的 Symbol 並不是什麼特別的物件，對於其他語言來說，他的功能就跟字串（幾乎）一模一樣。所以，或許 Ruby 中更令人好奇的是：Ruby 的字串到底為什麼長這樣？</p>

<p>原因之一可能要追溯到 Ruby 發明初期的強項：字串處理（String Manipulation）。試想今天在 Python 上，每當我們要對字串做處理時，我們每次都會需要重新 assign 一次（因為字串是不可變的）：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;&gt;&gt;</span> <span class="n">old_str</span> <span class="o">=</span> <span class="s">"abc"</span>
<span class="o">&gt;&gt;&gt;</span> <span class="n">new_str</span> <span class="o">=</span> <span class="n">old_str</span><span class="p">.</span><span class="n">upper</span><span class="p">()</span>
<span class="o">&gt;&gt;&gt;</span> <span class="n">new_str</span>
<span class="c1">#"ABC"
</span></code></pre></div></div>

<p>如果今天字串短，當然不是問題，但字串一大，每次 re-assign 就會使得我們的記憶體充斥著許多老舊字串，浪費記憶體空間。但在 Ruby 上就不一樣了：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;</span> <span class="n">str</span> <span class="o">=</span> <span class="s2">"abc"</span>
<span class="o">&gt;</span> <span class="n">str</span><span class="p">.</span><span class="nf">object_id</span>
<span class="c1">#=&gt; 18360</span>

<span class="o">&gt;</span> <span class="n">str</span><span class="p">.</span><span class="nf">upcase!</span>
<span class="o">&gt;</span> <span class="n">str</span>
<span class="c1">#=&gt; "ABC"</span>
<span class="o">&gt;</span> <span class="n">str</span><span class="p">.</span><span class="nf">object_id</span>
<span class="c1">#=&gt; 18360</span>
</code></pre></div></div>

<p>因爲字串自帶 <code class="language-plaintext highlighter-rouge">#upcase!</code> 方法，我們令字串使用後，字串就可以「自己修改自己」，但儲存位置完全不變！另外，因為不需要手動 re-assign，Ruby 的程式碼自然看起來乾淨、好讀、順暢多了，正符合 Ruby 當初設計「提升工程師幸福」的哲學。</p>

<h2 id="ruby-的-frozen_string_literal-true">Ruby 的 frozen_string_literal: true</h2>

<p>字串可變可以說是 Ruby 的一個雙面刃，它給了我們方便簡潔的方法進行字串處理，但卻同時造成兩個問題：</p>

<ol>
  <li>每次 assign 字串，即便內容完全一樣，都會被存在不同的位置，消耗記憶體空間</li>
  <li>我們有可能會不小心改變字串內容，使得調用同一個字串的方法得不到預期的 input</li>
</ol>

<p>因此，Ruby 在版本 2.3 以後開始推行預設字串凍結（freezing），也就是讓字串不可變，讓它們更像 Symbol 的特性。這也就是為什麼現在每一個 <code class="language-plaintext highlighter-rouge">.rb</code> 檔案的開頭都要先輸入一段<em>魔法指令</em>：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">#frozen_string_literal: true</span>
</code></pre></div></div>

<p>這可以確保這份檔案中的所有字串都預設調用 <code class="language-plaintext highlighter-rouge">#freeze</code> 方法，之後這個字串物件就不再可變了，如果試圖調用變動字串的方法就會產生 <code class="language-plaintext highlighter-rouge">FrozenError</code>：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;</span> <span class="n">str</span> <span class="o">=</span> <span class="s2">"abc"</span>
<span class="o">&gt;</span> <span class="n">str</span><span class="p">.</span><span class="nf">freeze</span> <span class="c1"># irb 中手動使用 #freeze 方法</span>
<span class="o">&gt;</span> <span class="n">str</span><span class="p">.</span><span class="nf">upcase!</span>
<span class="c1">#=&gt; can't modify frozen #&lt;Class:#&lt;String:0x000000012520d480&gt;&gt;: "abc" (FrozenError)</span>
</code></pre></div></div>

<p>這就使得 Ruby 的字串更像是其他語言的字串，也保障了變數的安全性。</p>

<h2 id="symbol-和-string-的轉換">Symbol 和 String 的轉換</h2>

<p>除了上面了 <code class="language-plaintext highlighter-rouge">#freeze</code> 方法讓 string 更像 symbol 之外，其實 Ruby 也有提供 symbol 和 string 之間型態互換的方法。</p>

<p>如果要將 Symbol 轉為 String，可以使用 <code class="language-plaintext highlighter-rouge">#to_s</code> 方法：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;</span> <span class="n">sym</span> <span class="o">=</span> <span class="ss">:abc</span>
<span class="o">&gt;</span> <span class="n">str</span> <span class="o">=</span> <span class="n">sym</span><span class="p">.</span><span class="nf">to_s</span>
<span class="o">&gt;</span> <span class="n">str</span><span class="p">.</span><span class="nf">class</span>
<span class="c1">#=&gt; String</span>
</code></pre></div></div>

<p>如果要將 String 轉為 Symbol，可以使用 <code class="language-plaintext highlighter-rouge">#to_sym</code> 方法：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;</span> <span class="n">str</span> <span class="o">=</span> <span class="s2">"abc"</span>
<span class="o">&gt;</span> <span class="n">sym</span> <span class="o">=</span> <span class="n">str</span><span class="p">.</span><span class="nf">to_sym</span>
<span class="o">&gt;</span> <span class="n">sym</span><span class="p">.</span><span class="nf">class</span>
<span class="c1">#=&gt; Symbol</span>
</code></pre></div></div>

<p>這讓我們有更多的彈性可以使用 Symbol 和 String 型別。</p>]]></content><author><name></name></author><summary type="html"><![CDATA[在 Ruby 中，有兩個長相相似但功能完全不同的物件：Symbol 以及 String（字串）。Ruby 是少數有 Symbol 物件的語言，而它的用法也很有趣。]]></summary></entry><entry><title type="html">SQL 的 INNER/LEFT/RIGHT/FULL JOIN</title><link href="https://kckhchen.com/blog/sql-joins/" rel="alternate" type="text/html" title="SQL 的 INNER/LEFT/RIGHT/FULL JOIN" /><published>2026-01-09T00:00:00+00:00</published><updated>2026-01-09T00:00:00+00:00</updated><id>https://kckhchen.com/blog/sql-joins</id><content type="html" xml:base="https://kckhchen.com/blog/sql-joins/"><![CDATA[<p>最近在複習 SQL 的 JOIN 子句，來來回回寫了 <a href="https://sqlzoo.net/wiki/SQL_Tutorial">SQL Zoo</a>、<a href="https://www.sqlteaching.com/">SQL Teaching</a> 和 <a href="https://sqlbolt.com/">SQL Bolt</a> 的好多題目後，對各種 JOIN Clause 有了更深一層的了解，因此想記錄下來作為未來的參考。另外，關於 SELF JOIN 的討論會放在下一篇文章。</p>

<h2 id="innerleftrightfull-join">INNER/LEFT/RIGHT/FULL JOIN</h2>

<p>在這四種 JOIN 裡面或許最常用的（也或許是裡面最直覺的）就是 <code class="language-plaintext highlighter-rouge">INNER JOIN</code>，不過因為這四個的關係其實密不可分，因此我認為放在一起整理是最有幫助的。</p>

<p>在想像<strong>兩個</strong> table 做 JOIN 時，我覺得最好的 mental model 就是用文氏圖思考：</p>

<p><img src="/blog/assets/images/venn.jpg" alt="" width="500" /></p>

<p>JOIN 背後的運算就是將兩個 table 串接起來，而串接的「節點」就是 A 的 Foreign Key 和 B 的 Primary Key（雖然也不必然是用 key 串接，但為方便解說先暫假設如此）。以下方的資料庫為例，我們創建一個「教授」表格和一個「系所」表格（我附上完整的資料庫創造指令，可以用 <a href="https://sqlize.online/">SQlize</a> 或類似網頁、程式執行）：</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">prof</span> <span class="p">(</span>
    <span class="n">id</span> <span class="nb">INT</span><span class="p">,</span>
    <span class="n">name</span> <span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">50</span><span class="p">),</span>
    <span class="n">deptid</span> <span class="nb">INT</span><span class="p">,</span>
    <span class="k">PRIMARY</span> <span class="k">KEY</span><span class="p">(</span><span class="n">id</span><span class="p">)</span>
<span class="p">);</span>

<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">dept</span> <span class="p">(</span>
    <span class="n">id</span> <span class="nb">INT</span><span class="p">,</span>
    <span class="n">name</span> <span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">50</span><span class="p">),</span>
    <span class="k">PRIMARY</span> <span class="k">KEY</span><span class="p">(</span><span class="n">id</span><span class="p">)</span>
<span class="p">);</span>

<span class="k">INSERT</span> <span class="k">INTO</span> <span class="n">prof</span>
<span class="k">VALUES</span>
    <span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="s1">'William'</span><span class="p">,</span> <span class="mi">10</span><span class="p">),</span>
    <span class="p">(</span><span class="mi">2</span><span class="p">,</span> <span class="s1">'Nina'</span><span class="p">,</span> <span class="mi">20</span><span class="p">),</span>
    <span class="p">(</span><span class="mi">3</span><span class="p">,</span> <span class="s1">'Lee'</span><span class="p">,</span> <span class="mi">20</span><span class="p">),</span>
    <span class="p">(</span><span class="mi">4</span><span class="p">,</span> <span class="s1">'Kevin'</span><span class="p">,</span> <span class="k">NULL</span><span class="p">);</span>
    
<span class="k">INSERT</span> <span class="k">INTO</span> <span class="n">dept</span>
<span class="k">VALUES</span>
    <span class="p">(</span><span class="mi">10</span><span class="p">,</span> <span class="s1">'Physics'</span><span class="p">),</span>
    <span class="p">(</span><span class="mi">20</span><span class="p">,</span> <span class="s1">'Chemistry'</span><span class="p">),</span>
    <span class="p">(</span><span class="mi">30</span><span class="p">,</span> <span class="s1">'Arts'</span><span class="p">);</span>
</code></pre></div></div>

<p>先稍微看一下資料庫的結構：</p>
<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="o">*</span> <span class="k">FROM</span> <span class="n">prof</span><span class="p">;</span>
<span class="c1">--|----|---------|--------|</span>
<span class="c1">--| id | name    | deptid |</span>
<span class="c1">--|----|---------|--------|</span>
<span class="c1">--| 1  | William | 10     |</span>
<span class="c1">--| 2  | Nina    | 20     |</span>
<span class="c1">--| 3  | Lee     | 20     |</span>
<span class="c1">--| 4  | Kevin   | [null] |</span>

<span class="k">SELECT</span> <span class="o">*</span> <span class="k">FROM</span> <span class="n">dept</span><span class="p">;</span>
<span class="c1">--|----|-----------|</span>
<span class="c1">--| id | name      |</span>
<span class="c1">--|----|-----------|</span>
<span class="c1">--| 10 | Physics   |</span>
<span class="c1">--| 20 | Chemistry |</span>
<span class="c1">--| 30 | Arts      |</span>
</code></pre></div></div>

<p>我們發現 <code class="language-plaintext highlighter-rouge">prof</code> 中的 <code class="language-plaintext highlighter-rouge">deptid</code> 可以和 <code class="language-plaintext highlighter-rouge">dept</code> 中的 <code class="language-plaintext highlighter-rouge">id</code> 連結，將這兩張表串接成一張表。不過 <code class="language-plaintext highlighter-rouge">dept</code> 當中有人沒有系所資料（<code class="language-plaintext highlighter-rouge">Kevin</code>），且 <code class="language-plaintext highlighter-rouge">dept</code> 中也有一個系所（<code class="language-plaintext highlighter-rouge">Arts</code>）沒有任何 <code class="language-plaintext highlighter-rouge">prof</code> 中的教授。這時一個問題就會在串接的過程中自然形成：</p>

<blockquote>
  <p>無法串接的資料要如何處理？</p>
</blockquote>

<h3 id="inner-join">INNER JOIN</h3>

<p><code class="language-plaintext highlighter-rouge">INNER JOIN</code> 的處理方式很直接，<strong>只要無法串接的資料全部丟棄</strong>。因此上面的兩個表單若透過 <code class="language-plaintext highlighter-rouge">INNER JOIN</code> 串接，<code class="language-plaintext highlighter-rouge">prof</code> 中的 <code class="language-plaintext highlighter-rouge">Kevin</code> 和 <code class="language-plaintext highlighter-rouge">dept</code> 中的 <code class="language-plaintext highlighter-rouge">Arts</code> 就會直接不見：</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="o">*</span>
<span class="k">FROM</span> <span class="n">prof</span> <span class="k">INNER</span> <span class="k">JOIN</span> <span class="n">dept</span> <span class="k">ON</span> <span class="n">prof</span><span class="p">.</span><span class="n">deptid</span> <span class="o">=</span> <span class="n">dept</span><span class="p">.</span><span class="n">id</span><span class="p">;</span>
<span class="c1">--|----|---------|--------|----|-----------|</span>
<span class="c1">--| id | name    | deptid | id | name      |</span>
<span class="c1">--|----|---------|--------|----|-----------|</span>
<span class="c1">--| 1  | William | 10     | 10 | Physics   |</span>
<span class="c1">--| 2  | Nina    | 20     | 20 | Chemistry |</span>
<span class="c1">--| 3  | Lee     | 20     | 20 | Chemistry |</span>
</code></pre></div></div>

<p>用文氏圖表示的話，就是只有中間的交集部分有被顯示出來：</p>

<p><img src="/blog/assets/images/venn-inner.jpg" alt="" width="500" /></p>

<p><code class="language-plaintext highlighter-rouge">INNER JOIN</code> 十分適合用在我們不在意無法配對的 entry 的時候（也就是大部分時候）。</p>

<h3 id="leftright-join">LEFT/RIGHT JOIN</h3>

<p><code class="language-plaintext highlighter-rouge">LEFT JOIN</code> 和 <code class="language-plaintext highlighter-rouge">RIGHT JOIN</code> 對於無法配對的 entry 處理的態度一樣：只保留其中一邊。他們唯一的差別在於 <code class="language-plaintext highlighter-rouge">LEFT JOIN</code> 選擇保留子句<strong>左邊</strong>的 entry，而 <code class="language-plaintext highlighter-rouge">RIGHT JOIN</code> 只保留子句<strong>右邊</strong>的 entry。並且在串接後的空白處補上空值 <code class="language-plaintext highlighter-rouge">NULL</code>（注意不會補上 <code class="language-plaintext highlighter-rouge">DEFAULT</code> 值）。沿用上面的資料庫：</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="o">*</span>
<span class="k">FROM</span> <span class="n">prof</span> <span class="k">LEFT</span> <span class="k">JOIN</span> <span class="n">dept</span> <span class="k">ON</span> <span class="n">prof</span><span class="p">.</span><span class="n">deptid</span> <span class="o">=</span> <span class="n">dept</span><span class="p">.</span><span class="n">id</span><span class="p">;</span>
<span class="c1">--|----|---------|--------|--------|-----------|</span>
<span class="c1">--| id | name    | deptid | id     | name      |</span>
<span class="c1">--|----|---------|--------|--------|-----------|</span>
<span class="c1">--| 1  | William | 10     | 10     | Physics   |</span>
<span class="c1">--| 2  | Nina    | 20     | 20     | Chemistry |</span>
<span class="c1">--| 3  | Lee     | 20     | 20     | Chemistry |</span>
<span class="c1">--| 4  | Kevin   | [null] | [null] | [null]    |</span>

<span class="k">SELECT</span> <span class="o">*</span>
<span class="k">FROM</span> <span class="n">prof</span> <span class="k">RIGHT</span> <span class="k">JOIN</span> <span class="n">dept</span> <span class="k">ON</span> <span class="n">prof</span><span class="p">.</span><span class="n">deptid</span> <span class="o">=</span> <span class="n">dept</span><span class="p">.</span><span class="n">id</span><span class="p">;</span>
<span class="c1">--|--------|---------|--------|----|-----------|</span>
<span class="c1">--| id     | name    | deptid | id | name      |</span>
<span class="c1">--|--------|---------|--------|----|-----------|</span>
<span class="c1">--| 1      | William | 10     | 10 | Physics   |</span>
<span class="c1">--| 3      | Lee     | 20     | 20 | Chemistry |</span>
<span class="c1">--| 2      | Nina    | 20     | 20 | Chemistry |</span>
<span class="c1">--| [null] | [null]  | [null] | 30 | Arts      |</span>
</code></pre></div></div>

<p>我們發現在 <code class="language-plaintext highlighter-rouge">LEFT JOIN</code> 的情況下，左邊的 <code class="language-plaintext highlighter-rouge">prof</code> 表格中的 <code class="language-plaintext highlighter-rouge">Kevin</code> 被保留了下來，不過卻因為沒有對應的 <code class="language-plaintext highlighter-rouge">deptid</code>，因此串接後的表格，<code class="language-plaintext highlighter-rouge">Kevin</code> 後面跟了一連串的空值。另外，右邊的表格 <code class="language-plaintext highlighter-rouge">dept</code> 中的 <code class="language-plaintext highlighter-rouge">Arts</code> 直接在組合後的表格中消失了。</p>

<p>這件事情在 <code class="language-plaintext highlighter-rouge">RIGHT JOIN</code> 的情況下相反：<code class="language-plaintext highlighter-rouge">Arts</code> 被保留下來，串接後無法配對的空間被補上 <code class="language-plaintext highlighter-rouge">NULL</code>，且 <code class="language-plaintext highlighter-rouge">Kevin</code> 不見了。</p>

<p><code class="language-plaintext highlighter-rouge">LEFT JOIN</code> 的情況，用文氏圖類比的話就會呈現下圖的狀態：</p>

<p><img src="/blog/assets/images/venn-left.jpg" alt="" width="500" /></p>

<p>也就是 Table A 的所有資料都留存，但如果 B 中有資料無法跟 A 串接的話，該資料就會直接被捨棄。相當於取集合 \(A \setminus B\)。而 <code class="language-plaintext highlighter-rouge">RIGHT JOIN</code> 的圖就是相反，我就不畫出來了。</p>

<p><code class="language-plaintext highlighter-rouge">LEFT JOIN</code> 因此很適合用在確保<strong>左邊的表格不能有資料丟失</strong>的情況下。例如我們想要清點學校教授人數，此時 <code class="language-plaintext highlighter-rouge">INNER JOIN</code> 就不適合，因為若有教授沒有 <code class="language-plaintext highlighter-rouge">deptid</code>，或他的 <code class="language-plaintext highlighter-rouge">deptid</code> 沒有對應的名稱，該教授的資料就會完全被丟棄。此時用 <code class="language-plaintext highlighter-rouge">LEFT JOIN</code> 就可以確保我們保有全部教授的資料，即便無法顯示系所名稱也沒關係。</p>

<h3 id="full-join">FULL JOIN</h3>

<p>最後的 <code class="language-plaintext highlighter-rouge">FULL JOIN</code> 的邏輯就很明顯了：我們希望保全兩邊所有資料，就算無法串接也沒關係，無法串接的空格就用 <code class="language-plaintext highlighter-rouge">NULL</code> 填充（註：某些 SQL Server 不支援 <code class="language-plaintext highlighter-rouge">FULL JOIN</code>）。</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="o">*</span>
<span class="k">FROM</span> <span class="n">prof</span> <span class="k">FULL</span> <span class="k">JOIN</span> <span class="n">dept</span> <span class="k">ON</span> <span class="n">prof</span><span class="p">.</span><span class="n">deptid</span> <span class="o">=</span> <span class="n">dept</span><span class="p">.</span><span class="n">id</span><span class="p">;</span>
<span class="c1">--|--------|---------|--------|--------|-----------|</span>
<span class="c1">--| id     | name    | deptid | id     | name      |</span>
<span class="c1">--|--------|---------|--------|--------|-----------|</span>
<span class="c1">--| 1      | William | 10     | 10     | Physics   |</span>
<span class="c1">--| 2      | Nina    | 20     | 20     | Chemistry |</span>
<span class="c1">--| 3      | Lee     | 20     | 20     | Chemistry |</span>
<span class="c1">--| 4      | Kevin   | [null] | [null] | [null]    |</span>
<span class="c1">--| [null] | [null]  | [null] | 30     | Arts      |</span>
</code></pre></div></div>

<p>用文氏圖來表示就會如下圖：</p>

<p><img src="/blog/assets/images/venn-full.jpg" alt="" width="500" /></p>

<p>這就相當於取兩個 table 的聯集（union），寫成集合表示法就是 \(A \cup B\)。</p>

<p>使用 FULL JOIN 可以避免我們漏掉任何 table 中的重要資訊，但也意味著我們可能會產生出許多的 <code class="language-plaintext highlighter-rouge">NULL</code>，在做某些 aggregation 運算時（如 <code class="language-plaintext highlighter-rouge">SUM()</code>）需要特別小心。</p>

<h2 id="結論">結論</h2>

<p>SQL 的 <code class="language-plaintext highlighter-rouge">JOIN</code> 系列就是這樣看了會懂，久了又會反覆忘記的知識，剛好這次讀出點心得，想透過淺顯易懂的解釋幫助有需要的人，也幫助未來再次忘記的自己。先感謝你把文章讀到這邊，如果你有興趣，我還寫了<a href="/blog/sql-self-join/">關於 SELF JOIN 有趣應用的文章</a>，歡迎過去看看。</p>

<script id="MathJax-script" async="" src="https://cdn.jsdelivr.net/npm/mathjax@4/tex-mml-chtml.js"></script>]]></content><author><name></name></author><summary type="html"><![CDATA[最近在複習 SQL 的 JOIN 子句，來來回回寫了 SQL Zoo、SQL Teaching 和 SQL Bolt 的好多題目後，對各種 JOIN Clause 有了更深一層的了解，因此想記錄下來作為未來的參考。另外，關於 SELF JOIN 的討論會放在下一篇文章。]]></summary></entry><entry><title type="html">從捷運轉乘問題看 SQL SELF JOIN</title><link href="https://kckhchen.com/blog/sql-self-join/" rel="alternate" type="text/html" title="從捷運轉乘問題看 SQL SELF JOIN" /><published>2026-01-09T00:00:00+00:00</published><updated>2026-01-09T00:00:00+00:00</updated><id>https://kckhchen.com/blog/sql-self-join</id><content type="html" xml:base="https://kckhchen.com/blog/sql-self-join/"><![CDATA[<p>在上一篇<a href="/blog/sql-joins/">談論不同 SQL JOINS 的文章中</a>，我們理解了不同 JOIN 的用法，卻沒有談及另一個也很常用的 JOIN 技巧，也就是 SELF JOIN。這系列問題一直是 SQL 中 JOIN 系列的大魔王。不過如果玩轉得當，SELF JOIN 也可以讓我們對資料做出很多很有趣的 query。這篇文章中，我從 <a href="https://sqlzoo.net/wiki/Self_join">SQL Zoo 的公車問題</a>中汲取靈感，想要用台北捷運的路線轉乘問題來介紹 SELF JOIN 的邏輯和有趣的應用。</p>

<h2 id="捷運資料庫">捷運資料庫</h2>

<p>首先我們先介紹這次主要會用到的 table <code class="language-plaintext highlighter-rouge">route</code>，SQL table 創建的完整指令可以在<a href="https://gist.github.com/kckhchen/5e6bfcd220b4cb7d35117215133111f0">我的 GitHub 找到</a>。資料內容是我從<a href="https://data.taipei/dataset/detail?id=733ff034-5a2b-442f-832a-c7d89add0ccb">台北捷運資料平台</a>抓下來整理的。裡面包含台北捷運五條初期路網（紅線、藍線、綠線、橘線、棕線）的 101 個站點資料<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>。其中 <code class="language-plaintext highlighter-rouge">lineid</code> 包含路線縮寫（BL, R, G, O, BR），<code class="language-plaintext highlighter-rouge">pos</code> 則是捷運順向（南向北、西向東）的停靠順序，<code class="language-plaintext highlighter-rouge">station</code> 則包含所有站點的中文名稱。透過下面的指令我們可以稍微概覽一下資料內容：</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="o">*</span> <span class="k">FROM</span> <span class="n">route</span> <span class="k">LIMIT</span> <span class="mi">5</span><span class="p">;</span>
<span class="c1">--|--------|-----|---------|</span>
<span class="c1">--| lineid | pos | station |</span>
<span class="c1">--|--------|-----|---------|</span>
<span class="c1">--| BR     | 1   | 動物園   |</span>
<span class="c1">--| BR     | 2   | 木柵     |</span>
<span class="c1">--| BR     | 3   | 萬芳社區  |</span>
<span class="c1">--| BR     | 4   | 萬芳醫院  |</span>
<span class="c1">--| BR     | 5   | 辛亥     |</span>
<span class="p">...</span>
</code></pre></div></div>

<h2 id="inner-join">INNER JOIN</h2>

<p>在真正開始處理問題前，我們先觀察 SELF JOIN 可以幫我們做什麼事。其實 SELF JOIN 就跟一般的 INNER/LEFT/RIGHT/FULL JOIN 一樣，只是 JOIN 的對象是自己。要怎麼 JOIN 自己呢？在實務上，我們會複製這份 table，然後讓它將那份複製的自己<strong>像是一份獨立的 table</strong> 一樣 JOIN 起來。</p>

<p>這樣做有什麼好處呢？以我們的捷運路線資料來說，我們可以將這份資料與自己 SELF JOIN，並且用 <code class="language-plaintext highlighter-rouge">lineid</code> 串接看看。兩份 table 分別簡單取名叫 <code class="language-plaintext highlighter-rouge">a</code> 和 <code class="language-plaintext highlighter-rouge">b</code> 就好：</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">a</span><span class="p">.</span><span class="n">station</span><span class="p">,</span> <span class="n">a</span><span class="p">.</span><span class="n">pos</span><span class="p">,</span> <span class="n">a</span><span class="p">.</span><span class="n">lineid</span><span class="p">,</span> <span class="n">b</span><span class="p">.</span><span class="n">lineid</span><span class="p">,</span> <span class="n">b</span><span class="p">.</span><span class="n">station</span>
<span class="k">FROM</span> <span class="n">route</span> <span class="n">a</span>
<span class="k">INNER</span> <span class="k">JOIN</span> <span class="n">route</span> <span class="n">b</span> <span class="k">ON</span> <span class="n">a</span><span class="p">.</span><span class="n">lineid</span> <span class="o">=</span> <span class="n">b</span><span class="p">.</span><span class="n">lineid</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">a</span><span class="p">.</span><span class="n">pos</span><span class="p">,</span> <span class="n">a</span><span class="p">.</span><span class="n">station</span> <span class="k">LIMIT</span> <span class="mi">5</span><span class="p">;</span>
<span class="c1">--|---------|-----|--------|--------|----------|</span>
<span class="c1">--| station | pos | lineid | lineid | station  |</span>
<span class="c1">--|---------|-----|--------|--------|----------|</span>
<span class="c1">--| 動物園    | 1   | BR     | BR     | 辛亥     |</span>
<span class="c1">--| 動物園    | 1   | BR     | BR     | 萬芳醫院  |</span>
<span class="c1">--| 動物園    | 1   | BR     | BR     | 木柵     |</span>
<span class="c1">--| 動物園    | 1   | BR     | BR     | 萬芳社區  |</span>
<span class="c1">--| 動物園    | 1   | BR     | BR     | 動物園    |</span>
</code></pre></div></div>

<p>我特地將一樣的 <code class="language-plaintext highlighter-rouge">a.lineid</code> 和 <code class="language-plaintext highlighter-rouge">b.lineid</code> 都顯示出來，確定兩個 table 的確是用 <code class="language-plaintext highlighter-rouge">lineid</code> 串接的。而由於棕線上的每一個站點都有 <code class="language-plaintext highlighter-rouge">BR</code> 的標籤，所以第一站「動物園」就會被串接上<strong>每一個棕線上的站</strong>（所以才會在資料中重複這麼多次）。當然，第二站「木柵」也會被串接上棕線的每一站。</p>

<p>發現了嗎？這等同於創造了一組「搭同一條線可以抵達的起終點站（包含同站上下車）」組合。無論從哪一站上車、哪一站下車，只要在同一條線上，我們的站點組合都會出現在這份資料中。也就是説，考量到每條路線的站點數量，這份 SELF JOIN 資料總共有 \(24^2 + 27^2 + 19^2 + 21^2 + 23^2 = 2636\) 種車站組合！</p>

<p>這就是 SELF JOIN 的強大之處。我們如果想看到<strong>兩列資料並排</strong>的結構，檢視不同列資料之間的關係，SELF JOIN 就可以幫我們完成。有了這樣的基礎，我們就可以開始看一些有趣的應用題目了。</p>

<h2 id="轉乘問題">轉乘問題</h2>

<h3 id="1-哪條線可以到中山">1. 哪條線可以到中山？</h3>

<p>這只是一個暖身問題，讓我們熟悉資料的結構。我們可以用：</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">lineid</span> <span class="k">FROM</span> <span class="n">route</span> <span class="k">WHERE</span> <span class="n">station</span> <span class="o">=</span> <span class="s1">'中山'</span><span class="p">;</span>
<span class="c1">--|--------|</span>
<span class="c1">--| lineid |</span>
<span class="c1">--|--------|</span>
<span class="c1">--| R      |</span>
<span class="c1">--| G      |</span>
</code></pre></div></div>

<p>會發現紅線（R）和綠線（G）都會經過中山站。</p>

<h3 id="2-從南邊搭紅線哪些站可以到中山">2. 從南邊搭紅線，哪些站可以到中山？</h3>

<p>從這題開始就真正進入到 SELF JOIN 的環節了。我們將題目拆解查看需求</p>
<ol>
  <li>搭紅線，所以 <code class="language-plaintext highlighter-rouge">lineid = 'R'</code></li>
  <li>從南邊出發，所以 <code class="language-plaintext highlighter-rouge">pos</code> 必須小於中山在紅線的 <code class="language-plaintext highlighter-rouge">pos</code></li>
</ol>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">a</span><span class="p">.</span><span class="n">lineid</span><span class="p">,</span> <span class="n">a</span><span class="p">.</span><span class="n">station</span> <span class="k">AS</span> <span class="n">fromStation</span><span class="p">,</span> <span class="n">b</span><span class="p">.</span><span class="n">station</span> <span class="k">AS</span> <span class="n">toStation</span>
<span class="k">FROM</span> <span class="n">route</span> <span class="n">a</span>
<span class="k">INNER</span> <span class="k">JOIN</span> <span class="n">route</span> <span class="n">b</span> <span class="k">ON</span> <span class="n">a</span><span class="p">.</span><span class="n">lineid</span> <span class="o">=</span> <span class="n">b</span><span class="p">.</span><span class="n">lineid</span>
<span class="k">WHERE</span> <span class="n">a</span><span class="p">.</span><span class="n">lineid</span> <span class="o">=</span> <span class="s1">'R'</span> <span class="c1">-- 路線是紅線</span>
  <span class="k">AND</span> <span class="n">b</span><span class="p">.</span><span class="n">station</span> <span class="o">=</span> <span class="s1">'中山'</span> <span class="c1">-- 終點站是中山</span>
  <span class="k">AND</span> <span class="n">a</span><span class="p">.</span><span class="n">pos</span> <span class="o">&lt;</span> <span class="n">b</span><span class="p">.</span><span class="n">pos</span> <span class="c1">-- 確保 pos 低於中山的 b.pos</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">a</span><span class="p">.</span><span class="n">pos</span><span class="p">;</span> <span class="c1">-- 最後由南到北排列</span>
</code></pre></div></div>

<p>也就是說，我們從剛才建立的 SELF JOIN 起訖站組合的資料中，篩選出 1. 紅線 2. 終點站 <code class="language-plaintext highlighter-rouge">b.station</code> 為中山 3. <code class="language-plaintext highlighter-rouge">pos</code> 比中山在紅線的 <code class="language-plaintext highlighter-rouge">pos</code> 還小的所有車站資料。最後由南到北排序。</p>

<p>我們就會得到這個結果：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>|--------|--------------|-----------|
| lineid | fromStation  | toStation |
|--------|--------------|-----------|
| R      | 象山          | 中山       |
| R      | 台北101/世貿   | 中山       |
| R      | 信義安和       | 中山       |
| R      | 大安          | 中山       |
| R      | 大安森林公園    | 中山       |
| R      | 東門          | 中山       |
| R      | 中正紀念堂     | 中山       |
| R      | 台大醫院       | 中山       |
| R      | 台北車站       | 中山       |
</code></pre></div></div>

<p>當然，我們都知道中山有兩條路線經過（紅綠），所以更有趣的問題應該是：我能不能直接找出哪些車站可以直達中山？</p>

<h3 id="3-哪些車站從南方可以直達中山">3. 哪些車站從南方可以直達中山？</h3>

<p>答案意外的簡單——我們只要把對於紅線的限制 <code class="language-plaintext highlighter-rouge">WHERE a.lineid = 'R'</code> 刪掉就好了，因為 <code class="language-plaintext highlighter-rouge">b.pos</code> 會依照中山現在是紅線還是綠線而做變化！</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">a</span><span class="p">.</span><span class="n">lineid</span><span class="p">,</span> <span class="n">a</span><span class="p">.</span><span class="n">station</span> <span class="k">AS</span> <span class="n">fromStation</span><span class="p">,</span> <span class="n">b</span><span class="p">.</span><span class="n">station</span> <span class="k">AS</span> <span class="n">toStation</span>
<span class="k">FROM</span> <span class="n">route</span> <span class="n">a</span>
<span class="k">INNER</span> <span class="k">JOIN</span> <span class="n">route</span> <span class="n">b</span> <span class="k">ON</span> <span class="n">a</span><span class="p">.</span><span class="n">lineid</span> <span class="o">=</span> <span class="n">b</span><span class="p">.</span><span class="n">lineid</span>
<span class="k">WHERE</span> <span class="n">b</span><span class="p">.</span><span class="n">station</span> <span class="o">=</span> <span class="s1">'中山'</span>
  <span class="k">AND</span> <span class="n">a</span><span class="p">.</span><span class="n">pos</span> <span class="o">&lt;</span> <span class="n">b</span><span class="p">.</span><span class="n">pos</span> <span class="c1">-- b.pos 會依照中山在紅線或綠線上而有不同！</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">a</span><span class="p">.</span><span class="n">lineid</span><span class="p">,</span> <span class="n">a</span><span class="p">.</span><span class="n">pos</span><span class="p">;</span>
</code></pre></div></div>

<p>回去思考 SELF JOIN 的邏輯其實就不會太意外：在做 SELF JOIN 時，我們是用 <code class="language-plaintext highlighter-rouge">lineid</code> 作為串接節點，因此紅線的中山（還有它的 <code class="language-plaintext highlighter-rouge">pos</code>）只會被串在紅線的車站上，而綠線站點串接上的是同樣有 <code class="language-plaintext highlighter-rouge">G</code> 標籤的中山，當然也會一起把綠線中山的 <code class="language-plaintext highlighter-rouge">pos</code> 帶過去。</p>

<p>因此，我們完全不需要知道哪些線可以到中山，我們只需要透過 <code class="language-plaintext highlighter-rouge">pos</code> 的篩選，就可以找到所有從西南方（或東北方）出發的所有車站！</p>

<p>我們會得到這樣的答案：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>|--------|-------------|-----------|
| lineid | fromStation | toStation |
|--------|-------------|-----------|
| G      | 新店         | 中山       |
| G      | 新店區公所    | 中山       |
| G      | 七張         | 中山       |
| G      | 大坪林       | 中山       |
| G      | 景美         | 中山       |
| G      | 萬隆         | 中山       |
| G      | 公館         | 中山       |
| G      | 台電大樓      | 中山       |
| G      | 古亭         | 中山       |
| G      | 中正紀念堂    | 中山        |
| G      | 小南門       | 中山        |
| G      | 西門         | 中山        |
| G      | 北門         | 中山        |
| R      | 象山         | 中山        |
| R      | 台北101/世貿  | 中山        |
| R      | 信義安和      | 中山        |
| R      | 大安         | 中山        |
| R      | 大安森林公園   | 中山        |
| R      | 東門         | 中山        |
| R      | 中正紀念堂    | 中山        |
| R      | 台大醫院      | 中山        |
| R      | 台北車站      | 中山        |
</code></pre></div></div>

<p>（注意到「中正紀念堂」站出現了兩次，因為中正紀念堂也是紅綠線的轉乘站，因此紅線跟綠線各自會顯示一次。）</p>

<h3 id="4-要如何南港展覽館轉乘一次到松山">4. 要如何南港展覽館「轉乘一次」到松山？</h3>

<p>這題才真正顯示出了 SELF JOIN 的實力，因為 SELF JOIN 可以做不只一次。我們可以透過多次串接，建立更複雜的關係。由於南港展覽館（藍、棕站）到松山（綠站）沒有直達車，中間勢必要經過轉乘。這個題目乍看很複雜，但其實可以簡單拆解成兩個子題：</p>
<ol>
  <li>從南港展覽館直達「轉乘站」</li>
  <li>從「轉乘站」直達松山</li>
</ol>

<p>上面兩題我們都已經做得很熟悉了，各自只需要一次 SELF JOIN 就可以完成。最後，只要再補上一個邏輯考量：兩個「轉乘站」必須是同一站。因此這裡要再多做一次 SELF JOIN。於是連續利用三個 SELF JOIN，我們就可以回答這個問題了：</p>

<div id="secidb4ccb8" class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">a</span><span class="p">.</span><span class="n">station</span> <span class="k">AS</span> <span class="n">fromStation</span><span class="p">,</span> <span class="n">a</span><span class="p">.</span><span class="n">lineid</span> <span class="k">AS</span> <span class="n">line_1</span> <span class="c1">-- 起點和第一段路線</span>
     <span class="p">,</span> <span class="n">b</span><span class="p">.</span><span class="n">station</span> <span class="k">AS</span> <span class="n">transfer</span> <span class="c1">-- 轉乘站名</span>
     <span class="p">,</span> <span class="n">d</span><span class="p">.</span><span class="n">lineid</span> <span class="k">AS</span> <span class="n">line_2</span><span class="p">,</span> <span class="n">d</span><span class="p">.</span><span class="n">station</span> <span class="k">AS</span> <span class="n">toStation</span> <span class="c1">-- 第二段路線和終點</span>
<span class="k">FROM</span> <span class="n">route</span> <span class="n">a</span> 
<span class="k">INNER</span> <span class="k">JOIN</span> <span class="n">route</span> <span class="n">b</span> <span class="k">ON</span> <span class="n">a</span><span class="p">.</span><span class="n">lineid</span> <span class="o">=</span> <span class="n">b</span><span class="p">.</span><span class="n">lineid</span> <span class="c1">-- 從起點到轉乘站</span>
<span class="k">INNER</span> <span class="k">JOIN</span> <span class="n">route</span> <span class="k">c</span> <span class="k">ON</span> <span class="n">b</span><span class="p">.</span><span class="n">station</span> <span class="o">=</span> <span class="k">c</span><span class="p">.</span><span class="n">station</span> <span class="c1">-- 確保兩個「轉乘站」一樣</span>
<span class="k">INNER</span> <span class="k">JOIN</span> <span class="n">route</span> <span class="n">d</span> <span class="k">ON</span> <span class="k">c</span><span class="p">.</span><span class="n">lineid</span> <span class="o">=</span> <span class="n">d</span><span class="p">.</span><span class="n">lineid</span> <span class="c1">-- 從轉乘站到終點</span>
<span class="k">WHERE</span> <span class="n">a</span><span class="p">.</span><span class="n">station</span> <span class="o">=</span> <span class="s1">'南港展覽館'</span> <span class="k">AND</span> <span class="n">d</span><span class="p">.</span><span class="n">station</span> <span class="o">=</span> <span class="s1">'松山'</span> <span class="c1">-- 設定起點、終點</span>
  <span class="k">AND</span> <span class="n">a</span><span class="p">.</span><span class="n">lineid</span> <span class="o">&lt;&gt;</span> <span class="n">d</span><span class="p">.</span><span class="n">lineid</span><span class="p">;</span> <span class="c1">-- 最後確保兩條線不是同一條線，不然就不是轉乘了</span>
</code></pre></div></div>

<p>結果就會顯示成：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>|-------------|--------|----------|--------|-----------|
| fromStation | line_1 | transfer | line_2 | toStation |
|-------------|--------|----------|--------|-----------|
| 南港展覽館    | BR     | 南京復興   | G      | 松山       |
| 南港展覽館    | BL     | 西門      | G      | 松山       |
</code></pre></div></div>

<p>這個 query 結果真是太美了！我知道我可以從棕線搭到南京復興轉乘綠線，或是從藍線搭到西門轉乘綠線，一目瞭然，而且只用了寥寥幾行的 SQL query！</p>

<h2 id="結論">結論</h2>

<p>希望這篇文章有讓大家更理解 SELF JOIN 的內部邏輯、應用方法、以及它的魅力。我過往一直都對 SELF JOIN 沒有什麼好感，直到碰到轉乘問題後，才發現 SELF JOIN 真的太好玩也太聰明了，於是迫不及待想寫篇文介紹。</p>

<p>現在，我們可以用同樣的邏輯，提出幾個閱後練習題：</p>
<ul>
  <li>我們能找出從「古亭」到「中山」單次轉乘，總共有多少種搭法嗎？（如果算出來是 5 種，小心有一種不能算是「轉乘」）</li>
</ul>

<div class="callout callout-hint">
  <details>
    <summary class="callout-title"><i class="callout-icon" data-lucide="flame"></i><span class="callout-title-text">解答</span></summary>
    <p>答案是 4 種。古亭到中山只需要將<a href="#secidb4ccb8">前一題</a>的 <code class="language-plaintext highlighter-rouge">a.station</code> 跟 <code class="language-plaintext highlighter-rouge">d.station</code> 改成古亭和中山，不過我們會發現多了一個 <code class="language-plaintext highlighter-rouge">| 古亭 | O | 古亭 | G | 中山 |</code>！也就是說，我們在古亭上橘線又馬上下車轉綠線去中山，這不能算轉乘，所以得要加上一個 <code class="language-plaintext highlighter-rouge">a.station &lt;&gt; b.station</code> 的條件。</p>
  </details>
</div>
<ul>
  <li>再多加一條限制就可以解決<a href="#secidb4ccb8">前一題</a>的問題，但為什麼文中的情況（南港展覽館 &gt; 松山）卻不用？</li>
</ul>

<div class="callout callout-hint">
  <details>
    <summary class="callout-title"><i class="callout-icon" data-lucide="flame"></i><span class="callout-title-text">解答</span></summary>
    <p>這是因為南港展覽館也是兩線的交會點，但是並沒有從南港展覽館 &gt; 松山的直達車，因此就算從南港展覽館同站上下車轉線，也不能抵達松山。當然，如果這題的限制變成「兩次轉乘」，就要小心這個問題了！因此其實 <code class="language-plaintext highlighter-rouge">a.station &lt;&gt; b.station</code> 以及 <code class="language-plaintext highlighter-rouge">c.station &lt;&gt; d.station</code> 是兩個很必要的條件。</p>
  </details>
</div>
<ul>
  <li>如果必須進行「兩次轉乘」，古亭到中山又有幾種搭法？</li>
</ul>

<div class="callout callout-hint">
  <details>
    <summary class="callout-title"><i class="callout-icon" data-lucide="flame"></i><span class="callout-title-text">解答</span></summary>
    <p>答案是 9 種！要進行兩次轉乘，我們就得要在現有的架構上再 SELF JOIN 兩次，加上 <code class="language-plaintext highlighter-rouge">d.station = e.station</code> 和 <code class="language-plaintext highlighter-rouge">e.lineid = f.lineid</code> 兩個設定，並且要小心，限制條件會有很多！除了不能同站轉乘（如 <code class="language-plaintext highlighter-rouge">a.station &lt;&gt; b.station</code>）、還要小心路途上的每一個轉乘站都必須不一樣（不然等於折返或繞路），以及不能搭回同一條線（一樣是繞路），總共要加上 7 條限制式。這題最容易錯的地方就是限制條件設定太少，讓可能性變多，所以要多檢查幾次：有沒有早就到了硬要繞路？又沒有搭回同一條線？有沒有同站轉乘？</p>
  </details>
</div>
<p>希望大家解題解得開心～</p>

<hr />

<h2 id="註解">註解</h2>

<!-- Obsidian Callout Styles - Generated by Obsidian2Jekyll -->
<style>
  .callout {
    padding: 10px 24px 2px;
    margin: 1.5em 0;
    border-radius: 4px;
    background-color: rgba(200, 200, 200, 0.2);
    --accent-clr: #9c9c9c;
  }

  .callout-icon {
    width: 1rem;
    margin-right: 0.5rem;
  }

  .callout-title {
    width: 80%;
    font-weight: bold;
    margin-bottom: 8px;
    display: flex;
    align-items: center;
    color: var(--accent-clr);
  }

  .callout-info,
  .callout-todo,
  .callout-note {
    background-color: rgba(160, 200, 255, 0.2);
    --accent-clr: #5d92ee;
    border-left-color: var(--accent-clr);
  }

  .callout-abstract,
  .callout-summary,
  .callout-tldr,
  .callout-tip,
  .callout-hint,
  .callout-important {
    background-color: rgba(155, 255, 235, 0.2);
    --accent-clr: #53d2d0;
    border-left-color: var(--accent-clr);
  }

  .callout-success,
  .callout-check,
  .callout-done {
    background-color: rgba(140, 255, 180, 0.2);
    --accent-clr: #37b94e;
    border-left-color: var(--accent-clr);
  }

  .callout-warning,
  .callout-question,
  .callout-help,
  .callout-faq,
  .callout-caution,
  .callout-attention {
    background-color: rgba(245, 190, 160, 0.2);
    --accent-clr: #ec7501;
    border-left-color: var(--accent-clr);
  }

  .callout-danger,
  .callout-error,
  .callout-bug,
  .callout-fail,
  .callout-failure,
  .callout-missing {
    background-color: rgba(255, 200, 205, 0.2);
    --accent-clr: #ea3d51;
    border-left-color: var(--accent-clr);
  }

  .callout-example {
    background-color: rgba(215, 195, 250, 0.2);
    --accent-clr: #a181ff;
    border-left-color: var(--accent-clr);
  }

  .callout-quote,
  .callout-cite {
    background-color: rgba(220, 220, 220, 0.2);
    --accent-clr: #ababab;
    border-left-color: var(--accent-clr);
  }

  details > summary:first-of-type::after {
    content: '▶';
    display: inline-block;
    position: relative;
    right: -1em;
    transition: transform 0.2s ease;
  }

  details[open] > summary:first-of-type::after {
    transform: rotate(90deg);
  }
</style>

<!-- Lucide CDN -->
<script src="https://unpkg.com/lucide@latest"></script>

<script>
  lucide.createIcons({
    attrs: {
      'stroke-width': 2.5,
      stroke: 'currentColor',
    },
  });
</script>

<script id="MathJax-script" async="" src="https://cdn.jsdelivr.net/npm/mathjax@4/tex-mml-chtml.js"></script>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p>為求方便，此資料沒有包含任何支線（小碧潭、新北投），橘線也僅有考慮迴龍方向的車輛。 <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name></name></author><summary type="html"><![CDATA[在上一篇談論不同 SQL JOINS 的文章中，我們理解了不同 JOIN 的用法，卻沒有談及另一個也很常用的 JOIN 技巧，也就是 SELF JOIN。這系列問題一直是 SQL 中 JOIN 系列的大魔王。不過如果玩轉得當，SELF JOIN 也可以讓我們對資料做出很多很有趣的 query。這篇文章中，我從 SQL Zoo 的公車問題中汲取靈感，想要用台北捷運的路線轉乘問題來介紹 SELF JOIN 的邏輯和有趣的應用。]]></summary></entry><entry><title type="html">測試驅動開發（TDD）在做什麼？</title><link href="https://kckhchen.com/blog/meeting-tdd/" rel="alternate" type="text/html" title="測試驅動開發（TDD）在做什麼？" /><published>2026-01-04T00:00:00+00:00</published><updated>2026-01-04T00:00:00+00:00</updated><id>https://kckhchen.com/blog/meeting-tdd</id><content type="html" xml:base="https://kckhchen.com/blog/meeting-tdd/"><![CDATA[<p>測試驅動開發（Test-Driven Development, TDD）是個我一見鍾情的概念，因為他解決了我長期以來寫程式時碰到的麻煩：</p>
<ol>
  <li>需要不停在 kernel 中重複複製、貼上測試的 code</li>
  <li>常常看見 error message 卻難以追溯哪個環節出問題</li>
  <li>未來修理一個 bug，因為沒搞清楚 dependencies，卻不小心搞壞整個程式</li>
  <li>Edge cases 總是想到才測試</li>
</ol>

<p>TDD 的核心理念就是一句話：「先寫測試，再寫程式」。在開始著手寫任何功能或函式之前，應該要先寫好測試碼，描述我希望獲得的結果，接著再開始撰寫函式，讓函式的輸出結果符合測試的標準。</p>

<h2 id="tdd-之前">TDD 之前</h2>

<p>舉我前陣子嘗試用 Ruby 開發的 CLI 西洋棋為例，我希望寫一個 helper function <code class="language-plaintext highlighter-rouge">input_to_coords</code> 將玩家的輸入的棋盤座標（例如 <code class="language-plaintext highlighter-rouge">a3</code>）轉換成程式中的 2d array 座標（例如 <code class="language-plaintext highlighter-rouge">[5, 0]</code> ）。</p>

<p>簡單！許多人腦中應該馬上就有解法了：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># class Board</span>
<span class="k">def</span> <span class="nf">input_to_coords</span><span class="p">(</span><span class="n">input</span><span class="p">)</span>
  <span class="n">input_array</span> <span class="o">=</span> <span class="n">input</span><span class="p">.</span><span class="nf">split</span><span class="p">(</span><span class="s1">''</span><span class="p">)</span>
  <span class="n">col</span> <span class="o">=</span> <span class="n">input_array</span><span class="p">.</span><span class="nf">first</span><span class="p">.</span><span class="nf">ord</span> <span class="o">-</span> <span class="s1">'a'</span><span class="p">.</span><span class="nf">ord</span>
  <span class="n">row</span> <span class="o">=</span> <span class="mi">8</span> <span class="o">-</span> <span class="n">input_array</span><span class="p">.</span><span class="nf">last</span><span class="p">.</span><span class="nf">to_i</span>
  <span class="p">[</span><span class="n">row</span><span class="p">,</span> <span class="n">col</span><span class="p">]</span>
<span class="k">end</span>
</code></pre></div></div>

<p>簡潔優雅。不過當我們打開 <code class="language-plaintext highlighter-rouge">irb</code> 測試時，馬上就會發現很多問題：首先，我們發現輸入空字串會報錯</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>'Board#input_to_coords': undefined method 'ord' for nil (NoMethodError)
</code></pre></div></div>

<p>所以我們又加上一個 Guard clause 在函式開頭：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">raise</span> <span class="no">IndexError</span> <span class="k">if</span> <span class="n">input</span><span class="p">.</span><span class="nf">empty?</span>
</code></pre></div></div>

<p>危機暫時解除。但不久後我們開始測試遊戲時，又發現另一個隱形錯誤，如果座標值超過盤面的話</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">board</span><span class="p">.</span><span class="nf">input_to_coords</span><span class="p">(</span><span class="s1">'a9'</span><span class="p">)</span>
<span class="c1"># =&gt; [-1, 0]</span>
</code></pre></div></div>

<p>這可不好。雖然不會報錯，但是它卻安靜地傳了一個我們不能用的值 <code class="language-plaintext highlighter-rouge">[-1, 0]</code>。我們需要隨時注意橫坐標（字母）和縱座標（數字）都沒有超出盤面座標才行。於是我們又加了一行在 return 之前的 guard clause：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">raise</span> <span class="no">IndexError</span> <span class="k">unless</span> <span class="n">col</span><span class="p">.</span><span class="nf">between?</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">7</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="n">row</span><span class="p">.</span><span class="nf">between?</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">7</span><span class="p">)</span>
</code></pre></div></div>

<p>到了這時，我們的函式已經越來越難讀了：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># class Board</span>
<span class="k">def</span> <span class="nf">input_to_coords</span><span class="p">(</span><span class="n">input</span><span class="p">)</span>
  <span class="k">raise</span> <span class="no">IndexError</span> <span class="k">if</span> <span class="n">input</span><span class="p">.</span><span class="nf">empty?</span>
  <span class="n">input_array</span> <span class="o">=</span> <span class="n">input</span><span class="p">.</span><span class="nf">split</span><span class="p">(</span><span class="s1">''</span><span class="p">)</span>
  <span class="n">col</span> <span class="o">=</span> <span class="n">input_array</span><span class="p">.</span><span class="nf">first</span><span class="p">.</span><span class="nf">ord</span> <span class="o">-</span> <span class="s1">'a'</span><span class="p">.</span><span class="nf">ord</span>
  <span class="n">row</span> <span class="o">=</span> <span class="mi">8</span> <span class="o">-</span> <span class="n">input_array</span><span class="p">.</span><span class="nf">last</span><span class="p">.</span><span class="nf">to_i</span>
  <span class="k">raise</span> <span class="no">IndexError</span> <span class="k">unless</span> <span class="n">col</span><span class="p">.</span><span class="nf">between?</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">7</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="n">row</span><span class="p">.</span><span class="nf">between?</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">7</span><span class="p">)</span>
  <span class="p">[</span><span class="n">row</span><span class="p">,</span> <span class="n">col</span><span class="p">]</span>
<span class="k">end</span>
</code></pre></div></div>

<p>有兩個在不同地方觸發的 guard clauses。或許當我們終於完成整個遊戲邏輯後，過了半個月，有使用者回報：「我希望輸入 <code class="language-plaintext highlighter-rouge">A6</code> 時不要報錯，因為我不喜歡小寫的 <code class="language-plaintext highlighter-rouge">a6</code>。」這時我們回去打開 500 行的 code，已經不知道從何改起，又擔心改變一個函式後，之前小心翼翼守住的所有 error 會不會又找到新的大門鑽出去。而且我們已經忘記當初做過哪些測試，所以無法一一確保我們改動完後，可以重新將半個月前做過的 20 個測試一字不差的跑一遍。</p>

<p>這通常會去許多非 TDD 專案最後會淪落的下場：沒有人膽敢 refactor，只好一直疊床架屋下去，或是就不修了。</p>

<h2 id="tdd-之後">TDD 之後</h2>

<p>TDD 要求程式設計師在動手寫任何程式碼之前，先把測試寫好。所以面對同樣一個 <code class="language-plaintext highlighter-rouge">input_to_coords</code> 函式，我會先創建一個 <code class="language-plaintext highlighter-rouge">spec/board_spec.rb</code> 的測試檔，並且開始思考：</p>

<blockquote>
  <p>我要這個函式做什麼事？它要收到什麼 input？什麼時候它要給出什麼樣的 output？</p>
</blockquote>

<p>首先，空字串當然要擋下來：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">describe</span> <span class="s1">'#input_to_coords'</span> <span class="k">do</span>
  <span class="n">it</span> <span class="s1">'should raise an error if input is empty'</span> <span class="k">do</span>
    <span class="n">board</span> <span class="o">=</span> <span class="no">Board</span><span class="p">.</span><span class="nf">new</span>
    <span class="n">expect</span> <span class="p">{</span> <span class="n">board</span><span class="p">.</span><span class="nf">input_to_coords</span><span class="p">(</span><span class="s1">''</span><span class="p">)</span> <span class="p">}.</span><span class="nf">to</span> <span class="n">raise_error</span><span class="p">(</span><span class="no">IndexError</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>接著呢？我們再思考，發現超出盤面的值也要擋下來：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="n">it</span> <span class="s1">'should raise an error if input is out of bound'</span> <span class="k">do</span>
    <span class="n">board</span> <span class="o">=</span> <span class="no">Board</span><span class="p">.</span><span class="nf">new</span>
    <span class="n">expect</span> <span class="p">{</span> <span class="n">board</span><span class="p">.</span><span class="nf">input_to_coords</span><span class="p">(</span><span class="s1">'a9'</span><span class="p">)</span> <span class="p">}.</span><span class="nf">to</span> <span class="n">raise_error</span><span class="p">(</span><span class="no">IndexError</span><span class="p">)</span>
  <span class="k">end</span>
</code></pre></div></div>

<p>但當然，如果 input 合法，我們也要讓函式做出正確的事：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="n">it</span> <span class="s1">'should return [7, 0] when the input is a1'</span> <span class="k">do</span>
    <span class="n">board</span> <span class="o">=</span> <span class="no">Board</span><span class="p">.</span><span class="nf">new</span>
    <span class="n">expect</span><span class="p">(</span><span class="n">board</span><span class="p">.</span><span class="nf">input_to_coords</span><span class="p">(</span><span class="s1">'a1'</span><span class="p">)).</span><span class="nf">to</span> <span class="n">eql</span><span class="p">([</span><span class="mi">7</span><span class="p">,</span> <span class="mi">0</span><span class="p">])</span>
  <span class="k">end</span>
</code></pre></div></div>

<p>現在，我們使用 <code class="language-plaintext highlighter-rouge">rspec</code> 跑測試檔，會發現<strong>全部失敗</strong>。這是當然的，因為我們還沒有寫任何程式！我們必須先讓紅字出現，確保 1. 我的測試檔自己沒有問題 2. <strong>不應該</strong>出現綠燈。所以全部紅字卻沒有報錯的時候，就代表  TDD 的第一步成功了。之後我們就可以開始寫函式。我們發現，空字串要擋掉，超過座標的也要擋掉，應該還有不少不合法的輸入需要擋，不如我們就直接寫一個 helper function 吧！</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># class Board</span>
<span class="k">def</span> <span class="nf">input_to_coords</span><span class="p">(</span><span class="n">input</span><span class="p">)</span>
  <span class="k">raise</span> <span class="no">IndexError</span> <span class="k">unless</span> <span class="n">input_valid?</span><span class="p">(</span><span class="n">input</span><span class="p">)</span>
  <span class="n">input_array</span> <span class="o">=</span> <span class="n">input</span><span class="p">.</span><span class="nf">split</span><span class="p">(</span><span class="s1">''</span><span class="p">)</span>
  <span class="n">col</span> <span class="o">=</span> <span class="n">input_array</span><span class="p">.</span><span class="nf">first</span><span class="p">.</span><span class="nf">ord</span> <span class="o">-</span> <span class="s1">'a'</span><span class="p">.</span><span class="nf">ord</span>
  <span class="n">row</span> <span class="o">=</span> <span class="mi">8</span> <span class="o">-</span> <span class="n">input_array</span><span class="p">.</span><span class="nf">last</span><span class="p">.</span><span class="nf">to_i</span>
  <span class="p">[</span><span class="n">row</span><span class="p">,</span> <span class="n">col</span><span class="p">]</span>
<span class="k">end</span>

<span class="kp">private</span>

<span class="k">def</span> <span class="nf">input_valid?</span><span class="p">(</span><span class="n">input</span><span class="p">)</span>
  <span class="kp">false</span> <span class="k">unless</span> <span class="o">!</span><span class="n">input</span><span class="p">.</span><span class="nf">empty?</span> <span class="o">&amp;&amp;</span> 
               <span class="n">input</span><span class="p">.</span><span class="nf">split</span><span class="p">(</span><span class="s1">''</span><span class="p">).</span><span class="nf">first</span><span class="p">.</span><span class="nf">between?</span><span class="p">(</span><span class="s1">'a'</span><span class="p">,</span> <span class="s1">'h'</span><span class="p">)</span> <span class="o">&amp;&amp;</span>
               <span class="n">input</span><span class="p">.</span><span class="nf">split</span><span class="p">(</span><span class="s1">''</span><span class="p">).</span><span class="nf">last</span><span class="p">.</span><span class="nf">to_i</span><span class="p">.</span><span class="nf">between?</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="mi">8</span><span class="p">)</span>
  <span class="kp">true</span>
<span class="k">end</span>
</code></pre></div></div>

<p>再跑一次測試檔，全部綠燈！但當然，這個 <code class="language-plaintext highlighter-rouge">input_valid?</code> 函式看起來太醜、太囉唆了，但至少我們已經確認我們的邏輯完美。下一步就是重構（refactor）。我們發現 <code class="language-plaintext highlighter-rouge">input_valid?</code> 這麼多條件，其實可以直接用一個 regex 確認：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">input_valid?</span><span class="p">(</span><span class="n">input</span><span class="p">)</span>
  <span class="n">regex</span> <span class="o">=</span> <span class="sr">/\A[a-h][1-8]\z/</span>
  <span class="n">regex</span><span class="p">.</span><span class="nf">match?</span><span class="p">(</span><span class="n">input</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>看起來清爽多了！但要小心這次的重構會不會讓我們的遊戲功能壞了，所以重構後再跑最後一次 test——全部綠燈！這就是 TDD 典型的 red-green-refactor 工作流：先讓 test 亮紅燈（red），再用各種方法符合 test 的需求（green），最後在不破壞邏輯的情況下重構程式碼（refactor）。每次要修改程式碼，就是不停的 red-green-refactor 三連拍的重複，就像在打節奏遊戲一樣，意外地療癒！（再加上 Ruby 自然流暢的語法，就像在寫 doc 一樣！）</p>

<h2 id="tdd-的好處">TDD 的好處</h2>

<p>TDD 的好處在此時就很明顯了：我們已經將所有預期的結果和可能會發生的錯誤「記載」在 spec 上面，因此我們不需要擔心每次手動測試時的方法不一致。而且，在寫 spec 時，我們腦中其實已經開始建構程式碼的邏輯，該做哪些事、哪些該報錯，return 要用什麼 type 呈現，都會在寫 spec 時漸漸成形，反而減少了程式碼未來產生衝突或不相容的問題。</p>

<p>TDD 另外最大的好處就是，假設半個月後那個使用者又來要求加入接受 <code class="language-plaintext highlighter-rouge">A6</code> 作為 input，我們現在有兩個非常完美的立足點：</p>
<ol>
  <li>因為 TDD 幫助我們寫了 clean code，我們可以馬上找到 <code class="language-plaintext highlighter-rouge">input_valid?</code> 是我們要 refactor 的對象。</li>
  <li>半個月前所有的 tests 都還在，我們在 refactor 後可以馬上測試新的程式碼會不會導致舊有功能失常。因為 tests 已經寫好了，只要一鍵就可以重新測試。</li>
</ol>

<p>這讓我們有充足的底氣和信心可以在 refactor 的同時不會不小心毀掉原本的功能，因為只要 refactor 後測試亮了紅燈，我們就可以立即知道是哪裡出了問題，是哪個 unit test 出錯了。</p>

<p>那，要如何修補使用者的要求呢？一樣，TDD 告訴我們要先寫一個 failed test：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="n">it</span> <span class="s1">'accepts cap letters as long as the input is valid'</span> <span class="k">do</span>
    <span class="n">board</span> <span class="o">=</span> <span class="no">Board</span><span class="p">.</span><span class="nf">new</span>
    <span class="n">expect</span><span class="p">(</span><span class="n">board</span><span class="p">.</span><span class="nf">input_to_coords</span><span class="p">(</span><span class="s1">'A6'</span><span class="p">)).</span><span class="nf">to</span> <span class="n">eql</span> <span class="p">[</span><span class="mi">2</span><span class="p">,</span> <span class="mi">0</span><span class="p">]</span>
  <span class="k">end</span>
</code></pre></div></div>

<p>跑一次測試，所有舊有的測試應該都要是綠燈，只有這個新的 unit test 是紅燈。接著，我們發現這個問題很好解決：我們要先讓 <code class="language-plaintext highlighter-rouge">input_valid?</code> 接受大寫，接著在 <code class="language-plaintext highlighter-rouge">input_to_coords</code> 中轉小寫就好：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># class Board</span>
<span class="k">def</span> <span class="nf">input_to_coords</span><span class="p">(</span><span class="n">input</span><span class="p">)</span>
  <span class="k">raise</span> <span class="no">IndexError</span> <span class="k">unless</span> <span class="n">input_valid?</span><span class="p">(</span><span class="n">input</span><span class="p">)</span>
  <span class="n">input_array</span> <span class="o">=</span> <span class="n">input</span><span class="p">.</span><span class="nf">downcase</span><span class="p">.</span><span class="nf">split</span><span class="p">(</span><span class="s1">''</span><span class="p">)</span> <span class="c1"># &lt;- 改了這裡</span>
  <span class="n">col</span> <span class="o">=</span> <span class="n">input_array</span><span class="p">.</span><span class="nf">first</span><span class="p">.</span><span class="nf">ord</span> <span class="o">-</span> <span class="s1">'a'</span><span class="p">.</span><span class="nf">ord</span>
  <span class="n">row</span> <span class="o">=</span> <span class="mi">8</span> <span class="o">-</span> <span class="n">input_array</span><span class="p">.</span><span class="nf">last</span><span class="p">.</span><span class="nf">to_i</span>
  <span class="p">[</span><span class="n">row</span><span class="p">,</span> <span class="n">col</span><span class="p">]</span>
<span class="k">end</span>

<span class="kp">private</span>

<span class="k">def</span> <span class="nf">input_valid?</span><span class="p">(</span><span class="n">input</span><span class="p">)</span>
  <span class="n">regex</span> <span class="o">=</span> <span class="sr">/\A[a-hA-H][1-8]\z/</span> <span class="c1"># &lt;- 改了這裡</span>
  <span class="n">regex</span><span class="p">.</span><span class="nf">match?</span><span class="p">(</span><span class="n">input</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>再跑一次 test，全部綠燈！當我第一次看到 CLI 跳出那一整排綠色的點時，我感覺到一種前所未有的踏實感。 我不再需要手動輸入座標到 <code class="language-plaintext highlighter-rouge">irb</code> 測試到手痠，這種『被程式保護著』的開發節奏，讓我真正體會到為什麼 TDD 會讓人一見鍾情。這時我們就可以放心地交貨，知道我們沒有把之前的功能搞砸，因為 TDD 讓我們謹守 clean code 原則，也記錄了我們過往的所有 tests 內容。</p>

<h2 id="結論">結論</h2>

<p>回到我一開始提出的煩惱，TDD 給出了非常好的回覆：</p>

<ol>
  <li>需要不停在 kernel 中重複複製、貼上測試的 code
-&gt; 寫一次 test，重復使用</li>
  <li>常常看見 error message 卻難以追溯哪個環節出問題
-&gt; 對於小 function 建立 test，限縮偵錯範圍</li>
  <li>未來修理一個 bug，因為沒搞清楚 dependencies，卻不小心搞壞整個程式
-&gt; 強迫 clean code，且幫我們記得過去的 tests</li>
  <li>Edge cases 總是想到才測試
-&gt; 在寫 test 時一併寫進去，以後再也不忘記！</li>
</ol>

<p>當然，這篇文章還有很多 TDD 的哲學沒有認真講述（例如 bare minimum 原則等等），但這個工作流程是目前最能和急性子的我合作無間的自我改良版。</p>

<p>以前改程式像不穿裝備走鋼索，每改一行代碼都心驚膽顫，深怕在哪個看不見的角落引發了連鎖反應。但自從遇見 TDD 就像有了保護繩，雖然「先寫測試」在初期看似慢了一點，但它賦予我的<strong>開發信心</strong>與<strong>程式碼穩定性</strong>，卻是往後數月甚至數年都受用無窮的。</p>

<p>畢竟，沒有什麼比看到那排綠色的 <code class="language-plaintext highlighter-rouge">.</code> 更能讓程式設計師睡個好覺了。</p>]]></content><author><name></name></author><summary type="html"><![CDATA[測試驅動開發（Test-Driven Development, TDD）是個我一見鍾情的概念，因為他解決了我長期以來寫程式時碰到的麻煩： 需要不停在 kernel 中重複複製、貼上測試的 code 常常看見 error message 卻難以追溯哪個環節出問題 未來修理一個 bug，因為沒搞清楚 dependencies，卻不小心搞壞整個程式 Edge cases 總是想到才測試]]></summary></entry><entry><title type="html">用 Shell Script 打造 Bitwarden 備份工具</title><link href="https://kckhchen.com/blog/minimal-bitwarden-backup/" rel="alternate" type="text/html" title="用 Shell Script 打造 Bitwarden 備份工具" /><published>2025-12-22T00:00:00+00:00</published><updated>2025-12-22T00:00:00+00:00</updated><id>https://kckhchen.com/blog/minimal-bitwarden-backup</id><content type="html" xml:base="https://kckhchen.com/blog/minimal-bitwarden-backup/"><![CDATA[<p>相信大家都跟我一樣是 Bitwarden 的愛用者（如果你不是，希望你至少有使用任何一種密碼管理員），並且享受它的開源、整合 MFA、雲端同步，以及離線存取等功能。但我想也有許多人與我有同樣的擔憂：將密碼全部交給 Bitwarden 保管在雲端，哪天突然無法離線存取密碼，或無法登入 Bitwarden 怎麼辦？</p>

<p>Bitwarden 將密碼儲存在雲端伺服器，讓用戶享受到裝置同步的便利，然而雖然它有離線存取功能，Bitwarden 的密碼卻是以加密的暫存檔（cache）的方式儲存在裝置上，並且離線使用 30 天後就會過期。為了保障我們總是保有對密碼的存取權，我們就需要定期備份密碼。</p>

<p>備份密碼又衍生了許多問題：匯出後的備份檔案全嗎？多久需要備份一次？如果忘記備份怎麼辦？定期備份產生出這麼多的備份檔要怎麼管理？</p>

<p>這些問題讓許多人望之卻步，也就乾脆放棄使用 Bitwarden 和其他密碼管理員。但這件問題其實是可以解決的！</p>

<p>Bitwarden 官方提供了命令介面（CLI）工具 <code class="language-plaintext highlighter-rouge">bitwarden-cli</code>，讓我們可以利用 Shell Script 輕鬆自己撰寫好用的備份工具，讓我們可以將整個備份旅程從頭到尾（定期提醒 → 一鍵備份 → 加密保護備份 → 自動清除老舊備份檔案）一條龍輕鬆完成！從此再也不會忘記備份密碼檔案，並且在任何有需要卻無法登入 Bitwarden 的時候，都可以從備份檔中輕鬆撈回自己的密碼！</p>

<p>以下將會一步一步介紹如何自己打造一個安全透明的定期備份精靈，也會在文章的最後附上我的 Shell Script 供有需要的人使用。</p>

<p>（以下將以 macOS 為使用情境為準，部分工具和指令在 Windows 或 Linux 系統上可能有所不同，請自行依照需求調整。）</p>
<h2 id="事前準備">事前準備</h2>

<p>使用 Shell Script 備份僅需要一個命令工具，也就是 <code class="language-plaintext highlighter-rouge">bitwarden-cli</code>。它可以用來登入、驗證、匯出加密檔案。<code class="language-plaintext highlighter-rouge">bitwarden-cli</code> 的詳細使用說明可以參照<a href="https://bitwarden.com/help/cli/">官方文件</a>。我們這次僅會用到其中幾種常見指令。官網有 <code class="language-plaintext highlighter-rouge">bitwarden-cli</code> 的<a href="https://bitwarden.com/help/cli/#download-and-install">簡易安裝檔</a>，或者 macOS 也可以透過 <code class="language-plaintext highlighter-rouge">npm</code> 指令安裝：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm <span class="nb">install</span> <span class="nt">-g</span> @bitwarden/cli
</code></pre></div></div>

<p>但在開始撰寫 Shell Script 之前，我們需要先從 Bitwarden 官方取得我們帳號的 API key。這可以讓我們在命令列直接與 Bitwarden 互動，而不需要透過 App 或是網頁擴充功能。取得 API key 的方法可以參照<a href="https://bitwarden.com/help/personal-api-key/">這份官方文件</a>。請注意我們需要兩個要素：<code class="language-plaintext highlighter-rouge">client_id</code> 和 <code class="language-plaintext highlighter-rouge">client_secret</code>。前者是公開的資訊（就像你的帳號），而後者是私密資訊，請務必小心保管！</p>

<p>有了 API key 之後，我們可以創建一個 <code class="language-plaintext highlighter-rouge">.env</code> 文件存放。在想要存放文件的位置使用下列指令：</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd </span>path/to/dir
<span class="nb">touch</span> .env
</code></pre></div></div>
<p>將 <code class="language-plaintext highlighter-rouge">path/to/dir</code> 替換為我們希望存放 Shell Script 檔案的位置。之後用文字編輯軟體（TextEdit、VSCode、TextMate、Nano 等）打開文件，把我們的 API key 裝進去：</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">BW_CLIENTID</span><span class="o">=</span><span class="s2">"client_id"</span>
<span class="nb">export </span><span class="nv">BW_CLIENTSECRET</span><span class="o">=</span><span class="s2">"client_secret"</span>
</code></pre></div></div>
<p>將 <code class="language-plaintext highlighter-rouge">client_id</code> 和 <code class="language-plaintext highlighter-rouge">client_secret</code> 替換成自己的 API key 後儲存檔案。之後在資料夾中使用下列指令：</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">chmod </span>600 .env
</code></pre></div></div>
<p>這個指令會限制 <code class="language-plaintext highlighter-rouge">.env</code> 的存取權限，僅有檔案的擁有者（也就是你）可以打開、閱覽、編輯這份檔案，其他使用者都無法打開。另外，即便發現自己的 API key 有被盜用的危機，也不用擔心，只要登入 Bitwarden 網站就可以重新生成一組 <code class="language-plaintext highlighter-rouge">client_secret</code>，並自動使舊的 API key 作廢。另外，即便對方取得你的 API key，只要沒有你的 Bitwarden master password，也是完全無法使用或存取你的密碼庫的，在安全方面 Bitwarden 確實做得非常到位。</p>

<p>前置作業完成後，就可以開始寫 Script 了！</p>

<h2 id="撰寫腳本">撰寫腳本</h2>

<p>這份腳本主要會有四大步驟：1. 登入 2. 解鎖 3. 取得登入階段 4. 匯出密碼。</p>

<h3 id="登入">登入</h3>

<p>我們先把剛才的 API key 匯入腳本中：</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">source</span> .env
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">bitwarden-cli</code> 的登入指令是：</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bw login
</code></pre></div></div>
<p>就這麼簡單！不過，預設的登入方式會要求你輸入 Bitwarden 的註冊信箱和 master password，太麻煩了，我們直接請 API key 幫我們登入：</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bw login <span class="nt">--apikey</span>
</code></pre></div></div>
<p>此時我們剛才存在 <code class="language-plaintext highlighter-rouge">.env</code> 當中的 API key 就派上用場了！輸入這個指令後，Bitwarden 會自動讀取腳本環境中的 <code class="language-plaintext highlighter-rouge">BW_CLIENTID</code> 和 <code class="language-plaintext highlighter-rouge">BW_CLIENTSECRET</code> 變數，如果資料正確就會自動登入，完全不用動一根手指！</p>

<h3 id="解鎖">解鎖</h3>

<p>不過誠如剛才所說，要能夠存取密碼，我們還需要<strong>解鎖</strong>我們的密碼庫，這時就需要使用到我們的 master password。使用這個指令可以解鎖：</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bw unlock
</code></pre></div></div>
<p>但先不要使用這個指令，我們不只要解鎖而已！</p>

<h3 id="取得登入階段">取得登入階段</h3>

<p>Bitwarden CLI 在解鎖之後，會給你一段階段代碼（session key）用以證明你已經成功登入。從解鎖之後到使用指令上鎖、登出，或關閉目前的 CLI 前，只要提供 Bitwarden 這段 session key，就可以進行所有帳戶操作而不用再輸入 master password，就像是臨時通行證一樣！</p>

<p>如果單純使用 <code class="language-plaintext highlighter-rouge">bw unlock</code>，Bitwarden 會在命令列要求我們輸入 master password。輸入正確後，會回傳一段很長的 session key 給我們，長得可能像這樣：</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>BW_SESSION="IGJicWrJXTiHFhmF0f/8uzYqSzyM7SDfVWrgfwISBzHV8mRRaJyhuTPJALgoBmCgUr9q3wnk7Ccv7locu5AYjw=="
</code></pre></div></div>
<p>接著，它會要求我們廣播（export）這份 session key，讓後續指令使用。我們可以將這兩步驟結合成一步：</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>export BW_SESSION=$(bw unlock --raw)
</code></pre></div></div>
<p>這段指令會先執行 <code class="language-plaintext highlighter-rouge">$()</code> 內的 <code class="language-plaintext highlighter-rouge">bw unlock --raw</code>，其中 <code class="language-plaintext highlighter-rouge">--raw</code> 會讓 Bitwarden 在驗證完成後直接回傳 session key 字串，之後這個字串會再被 <code class="language-plaintext highlighter-rouge">BW_SESSION</code> 接收，並使用 <code class="language-plaintext highlighter-rouge">export</code> 廣播給後續指令使用。很酷吧！</p>

<p>從現在開始我們才能大展拳腳，因為所有指令都能自由使用了。使用指令時，指令會尋找在腳本的環境中的 <code class="language-plaintext highlighter-rouge">BW_SESSION</code>，只要確認正確，就會直接執行指令，不需要再要求 master password。</p>

<h3 id="匯出密碼">匯出密碼</h3>

<p>這是最關鍵的一步！匯出密碼的常用指令如下：</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bw export --format --password --output
</code></pre></div></div>
<p>我們一項一項解釋：<code class="language-plaintext highlighter-rouge">--format</code> 要求我們輸入匯出格式，常用的有 <code class="language-plaintext highlighter-rouge">json</code>、<code class="language-plaintext highlighter-rouge">encrypted_json</code> 和 <code class="language-plaintext highlighter-rouge">csv</code>。為了保障我們的密碼安全，這裡建議使用 <code class="language-plaintext highlighter-rouge">encrypted_json</code>。接著，<code class="language-plaintext highlighter-rouge">--password</code> 是專門配合 <code class="language-plaintext highlighter-rouge">encrypted_json</code> 的參數，要求我們設定這個加密檔案的密碼。最後，<code class="language-plaintext highlighter-rouge">--output</code> 要求我們輸入密碼檔案的匯出路徑。當我們都決定好之後，就可以開始匯出：</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bw export --format encrypted_json --password "my-powerful-password" --output "~/Backups/Backup-$(date +%F).json"
</code></pre></div></div>
<p>請將 <code class="language-plaintext highlighter-rouge">my-powerful-password</code> 換成自己的密碼！而且請務必和 master password 一樣堅固，否則就有機會成為被盜取密碼的破口！另外 <code class="language-plaintext highlighter-rouge">~/Backups/Backup-$(date +%F).json</code> 會將備份檔案儲存在使用者根目錄之下的 <code class="language-plaintext highlighter-rouge">Backups</code> 資料夾，並且檔案會命名為 <code class="language-plaintext highlighter-rouge">Backup-yyyy-mm-dd.json</code>，也就是備份的日期。假設今天是 2025 年 12 月 20 日，備份資料就會是 <code class="language-plaintext highlighter-rouge">Backup-2025-12-20.json</code>，方便我們知道何時備份了密碼。如果需要更改儲存位置也請自行在此更改。</p>

<div class="callout callout-warning">
  <div class="callout-title"><i class="callout-icon" data-lucide="circle-alert"></i><span class="callout-title-text">Warning</span></div>
  <p>（警告：利用此種方法設定密碼有一個致命缺點，也就是備份檔案的密碼會以純文字的形式存在這份 Shell Script 當中，也就是若有人開啟這份 Shell Script 就可以直接讀到你的備份檔案密碼！其實這裡也可以再叫出一個 prompt 讓使用者自行輸入每次的密碼，不過就不在本次的介紹範圍中。總之若要自己從頭建立這份 Shell Script，請務必使用額外的加密管道保障自己的安全！）另外，文章最後提供的工具包可以很大一部分減輕這個指令帶來的風險。</p>
</div>
<h3 id="登出">登出</h3>

<p>這樣就大功告成了！使用完成後別忘了登出：</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bw logout
</code></pre></div></div>
<p>登出後當次的 session key 就會失效，確保我們的帳戶安全。（如果忘記登出，關閉 CLI 介面後 Bitwarden 也會自動登出）。</p>

<h3 id="包裝腳本">包裝腳本</h3>

<p>我們將上面的指令內容全部集合在一份 <code class="language-plaintext highlighter-rouge">bitwarden-backup.sh</code> 的 Shell Script 當中，並在 Shell Script 第一行加上 <code class="language-plaintext highlighter-rouge">#!/bin/zsh</code>（讓電腦知道要用哪個軟體跑腳本）：</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/zsh</span>

<span class="nb">cd </span>path/to/dir
<span class="nb">source</span> .env
bw login <span class="nt">--apikey</span>
<span class="nb">export </span><span class="nv">BW_SESSION</span><span class="o">=</span><span class="si">$(</span>bw unlock <span class="nt">--raw</span><span class="si">)</span>
bw <span class="nb">export</span> <span class="nt">--format</span> encrypted_json <span class="nt">--password</span> <span class="s2">"my-powerful-password"</span> <span class="nt">--output</span> <span class="s2">"~/Backups/Backup-</span><span class="si">$(</span><span class="nb">date</span> +%F<span class="si">)</span><span class="s2">.json"</span>
bw <span class="nb">logout</span>
</code></pre></div></div>

<p>並且從 CLI 使用指令：</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">chmod</span> +x path/to/dir/bitwarden-backup.sh
</code></pre></div></div>
<p>這條指令可以讓我們未來直接運行這份腳本，不用打開一條一條複製貼上（也就是給腳本執行的權限）。以後想要備份資料時，只要使用 CLI 進入（<code class="language-plaintext highlighter-rouge">cd</code>）腳本所在的資料夾，並使用：</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>./bitwarden-backup.sh
</code></pre></div></div>
<p>或是直接輸入絕對路徑：</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>path/to/dir/bitwarden-backup.sh
</code></pre></div></div>

<p>所有指令就會自動運行了！（當然你還是要輸入 master password）完美實現一鍵安全備份！</p>

<h2 id="設定排程提醒">設定排程提醒</h2>

<p>備份變容易是一回事，會記得備份就是另一回事了。要怎麼樣才能讓電腦提醒我們自動備份呢？這時我們可以利用 macOS 系統裡自帶的 cron 進行定期任務。cron 特殊的表示法，可以固定週期地運行某個腳本指令。cron 表示法非常簡單：</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>* * * * * path/to/script
</code></pre></div></div>
<p>五個米字號分別代表「分、時、日、月、星期」。只要將米字號換成對應的數字，只要時間全數符合標準，後面的腳本就會運行。舉例而言，如果我希望每天下午三點半運行腳本 <code class="language-plaintext highlighter-rouge">test.sh</code>，我就使用</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>30 15 * * * /path/to/test.sh
</code></pre></div></div>
<p>或是，如果我希望每個月一號中午十二點運行 <code class="language-plaintext highlighter-rouge">test.sh</code>，我就使用</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>0 12 1 * * /path/to/test.sh
</code></pre></div></div>
<p>當然，這個表示法還有很多不同的用法，如果沒有辦法拼出自己要的，<a href="https://crontab.guru/">crontab guru</a>是個非常好的查詢網站（或是直接問 AI 也可以XD)，由於 cron 不是本次介紹的重點，關於它的介紹在這裡就點到為止。</p>

<p>有了 cron 之後，我們就可以來寫一份迷你的提醒腳本 <code class="language-plaintext highlighter-rouge">backup-alert.sh</code>（如果你不介意時間到就會有 CLI 介面飛到你臉上也可以直接連到主要腳本XD）。不過為了方便電腦送通知給我們，我們需要用到 <code class="language-plaintext highlighter-rouge">terminal-notifier</code> 這份<a href="https://github.com/julienXX/terminal-notifier">小工具</a>。macOS 使用者可以利用 Homebrew 進行安裝：</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>brew <span class="nb">install </span>terminal-notifier
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">terminal-notifier</code> 非常簡單好用，基本常用指令如下：</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>terminal-notifier <span class="nt">-title</span> <span class="nt">-message</span> <span class="nt">-execute</span>
</code></pre></div></div>
<p>其中 <code class="language-plaintext highlighter-rouge">-title</code> 後面輸入通知標題，<code class="language-plaintext highlighter-rouge">-message</code> 是通知內容，<code class="language-plaintext highlighter-rouge">-execute</code> 則是點擊通知後要運行的 CLI 指令。例如：</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>terminal-notifier <span class="nt">-title</span> <span class="s2">"Backup Reminder"</span> <span class="nt">-message</span> <span class="s2">"Click here to start your backup"</span> <span class="nt">-execute</span> <span class="s2">"open -a Terminal </span><span class="se">\"</span><span class="s2">path/to/dir/bitwarden-backup.sh</span><span class="se">\"</span><span class="s2">"</span>
</code></pre></div></div>

<p>也就是說，這份 Shell Script 運行時會傳送一個通知到電腦上，而當我們點擊通知後，它就會運行一段指令：<code class="language-plaintext highlighter-rouge">open -a Terminal</code> 打開 CLI 介面，並且使用 <code class="language-plaintext highlighter-rouge">path/to/dir/bitwarden-backup.sh</code> 呼叫備份 Shell Script。我們最後只需要在打開的 CLI 介面中輸入 master password 就完成了！</p>

<h3 id="組裝腳本">組裝腳本</h3>

<p>最後是大組裝的階段。我們先將 <code class="language-plaintext highlighter-rouge">backup-alert.sh</code> 寫成如下，並存在和 <code class="language-plaintext highlighter-rouge">bitwarden-backup.sh</code> 一樣的資料夾下：</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/zsh</span>
<span class="nb">export </span><span class="nv">PATH</span><span class="o">=</span><span class="s2">"/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:</span><span class="nv">$PATH</span><span class="s2">"</span>
terminal-notifier <span class="nt">-title</span> <span class="s2">"Backup Reminder"</span> <span class="nt">-message</span> <span class="s2">"Click here to start your backup"</span> <span class="nt">-execute</span> <span class="s2">"open -a Terminal </span><span class="se">\"</span><span class="s2">path/to/dir/bitwarden-backup.sh</span><span class="se">\"</span><span class="s2">"</span>
</code></pre></div></div>
<p>其中的 <code class="language-plaintext highlighter-rouge">export PATH="..."</code> 非常重要，因為 cron 在自動運行時，不像我們使用的 CLI 介面這麼聰明，沒有非常明確的指示，它不知道 <code class="language-plaintext highlighter-rouge">terminal-notifier</code> 指令在哪裡，所以需要加上一個 <code class="language-plaintext highlighter-rouge">PATH</code> 變數讓它認路，這個變數不需要修改，保持原樣就好。之後老樣子，我們要給這份腳本執行權限：</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">chmod</span> +x /path/to/dir/backup-alert.sh
</code></pre></div></div>

<p>最後，在 CLI 中編輯管理排程的 <code class="language-plaintext highlighter-rouge">crontab</code>，輸入：</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>crontab <span class="nt">-e</span>
</code></pre></div></div>
<p>裡面輸入我們希望他運行的週期，我個人建議每個月備份一次，因此設定在每月第一天的中午十二點：</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>0 12 1 * * /path/to/dir/backup-alert.sh
</code></pre></div></div>
<p>存檔後關閉文件，並且在彈出視窗中允許 cron 運行的權限就可以了。</p>

<h2 id="大功告成">大功告成</h2>

<p>這樣所有的設定就完成了！現在你的資料夾當中應該會有三份檔案：負責備份的 <code class="language-plaintext highlighter-rouge">bitwarden-backup.sh</code>、負責提醒的 <code class="language-plaintext highlighter-rouge">backup-alert.sh</code>，以及存著 API key 的 <code class="language-plaintext highlighter-rouge">.env</code>。另外，<code class="language-plaintext highlighter-rouge">crontab</code> 文件當中應該會有一串呼叫 <code class="language-plaintext highlighter-rouge">backup-alert.sh</code> 的指令。未來的工作流程大概會像這樣：</p>

<p>每個月一日中午十二點（或你設定的時間） → 收到通知 → 點擊通知 → 跟隨指示輸入 master password → 腳本自動完成匯出並結束</p>

<p>就是這麼輕鬆方便！我們同時享受了 Bitwarden 雲端同步的好處和密碼管理員的安全性，也透過定期自動提醒備份在電腦儲存空間中存了受密碼保護的備份檔。如此一來，便可以安心享受 Bitwarden 帶來的便利性，也不用提心吊膽了！</p>

<h2 id="能不能更進一步">能不能更進一步？</h2>

<p>當然可以！上面所介紹的腳本非常陽春，基本上還是需要我們動手在 CLI 介面輸入 master password，並且會有許多的 CLI 通知在介面上跑來跑去。因此我透過上方介紹的架構，設計了<a href="https://github.com/kckhchen/Minimalistic-Bitwarden-Backup">完整的自動備份小工具</a>。這份小工具在初始設定完成後，我們從此不用再和 CLI 打交道，所有個備份、通知、密碼輸入，都可在 macOS 原生的 GUI 對話視窗中完成！這份小工具甚至會定期幫我們打掃備份資料夾，只留下最近幾份備份檔案，將老舊備份刪除，避免空間浪費，也加強密碼安全。</p>

<p>另外，有時候 Bitwarden CLI 會因為不明原因驗證失敗（回傳空的 session key），如果你發現腳本偶爾沒反應，這可能是原因之一。我的進階版工具中加入了自動重試機制來解決這個問題。</p>

<p>如果你對更深入的完整開發有興趣，歡迎去看看我的原始碼，如果有任何問題或是建議，也歡迎跟我說～</p>

<!-- Obsidian Callout Styles - Generated by Obsidian2Jekyll -->
<style>
  .callout {
    padding: 10px 24px 2px;
    margin: 1.5em 0;
    border-radius: 4px;
    background-color: rgba(200, 200, 200, 0.2);
    --accent-clr: #9c9c9c;
  }

  .callout-icon {
    width: 1rem;
    margin-right: 0.5rem;
  }

  .callout-title {
    width: 80%;
    font-weight: bold;
    margin-bottom: 8px;
    display: flex;
    align-items: center;
    color: var(--accent-clr);
  }

  .callout-info,
  .callout-todo,
  .callout-note {
    background-color: rgba(160, 200, 255, 0.2);
    --accent-clr: #5d92ee;
    border-left-color: var(--accent-clr);
  }

  .callout-abstract,
  .callout-summary,
  .callout-tldr,
  .callout-tip,
  .callout-hint,
  .callout-important {
    background-color: rgba(155, 255, 235, 0.2);
    --accent-clr: #53d2d0;
    border-left-color: var(--accent-clr);
  }

  .callout-success,
  .callout-check,
  .callout-done {
    background-color: rgba(140, 255, 180, 0.2);
    --accent-clr: #37b94e;
    border-left-color: var(--accent-clr);
  }

  .callout-warning,
  .callout-question,
  .callout-help,
  .callout-faq,
  .callout-caution,
  .callout-attention {
    background-color: rgba(245, 190, 160, 0.2);
    --accent-clr: #ec7501;
    border-left-color: var(--accent-clr);
  }

  .callout-danger,
  .callout-error,
  .callout-bug,
  .callout-fail,
  .callout-failure,
  .callout-missing {
    background-color: rgba(255, 200, 205, 0.2);
    --accent-clr: #ea3d51;
    border-left-color: var(--accent-clr);
  }

  .callout-example {
    background-color: rgba(215, 195, 250, 0.2);
    --accent-clr: #a181ff;
    border-left-color: var(--accent-clr);
  }

  .callout-quote,
  .callout-cite {
    background-color: rgba(220, 220, 220, 0.2);
    --accent-clr: #ababab;
    border-left-color: var(--accent-clr);
  }

  details > summary:first-of-type::after {
    content: '▶';
    display: inline-block;
    position: relative;
    right: -1em;
    transition: transform 0.2s ease;
  }

  details[open] > summary:first-of-type::after {
    transform: rotate(90deg);
  }
</style>

<!-- Lucide CDN -->
<script src="https://unpkg.com/lucide@latest"></script>

<script>
  lucide.createIcons({
    attrs: {
      'stroke-width': 2.5,
      stroke: 'currentColor',
    },
  });
</script>]]></content><author><name></name></author><summary type="html"><![CDATA[相信大家都跟我一樣是 Bitwarden 的愛用者（如果你不是，希望你至少有使用任何一種密碼管理員），並且享受它的開源、整合 MFA、雲端同步，以及離線存取等功能。但我想也有許多人與我有同樣的擔憂：將密碼全部交給 Bitwarden 保管在雲端，哪天突然無法離線存取密碼，或無法登入 Bitwarden 怎麼辦？]]></summary></entry><entry><title type="html">貝氏統計如何做機率更新？</title><link href="https://kckhchen.com/blog/bayesian-update/" rel="alternate" type="text/html" title="貝氏統計如何做機率更新？" /><published>2025-05-23T00:00:00+00:00</published><updated>2025-05-23T00:00:00+00:00</updated><id>https://kckhchen.com/blog/bayesian-update</id><content type="html" xml:base="https://kckhchen.com/blog/bayesian-update/"><![CDATA[<p><a href="/blog/bayesian/">上一篇文章</a>中我試著用直覺的方式介紹了貝氏統計的哲學：先驗機率以及機率更新。這篇文章將會深入數學的部分，介紹貝氏統計如何善用機率函數的特性以及鼎鼎大名的貝氏定理，從先驗機率和概似函數的計算中，用簡單的方法推算出後驗機率。</p>

<h2 id="核kernel是什麼">核（kernel）是什麼？</h2>

<p>在瞭解貝氏統計如何做機率更新之前，我們需要先重新認識機率函數。</p>

<p>一般來說，機率函數可以被拆解成兩個部分：核（kernel）以及歸一常數（normalizing constant）。以下方的高斯機率密度函數（或稱常態分配，normal distribution）為例：</p>

\[\frac{1}{\sqrt{ 2\pi \sigma^{2} }} \boxed {\exp \left( -\frac{1}{2\sigma^{2}}(x-\mu)^{2} \right) }\]

<p>上圖中方框內的就是高斯機率函數的核，方框外的則是歸一常數。核是一個機率函數的核心，它控制了機率函數會如何表現，然而歸一常數的存在，顧名思義，僅僅是為了確保機率函數的加總（或是積分）為 1，以符合機率函數的定義。也就是說，要分辨不同的機率函數，只需要認出他們的核即可，不同的機率函數就會有不同的核，而核定義好之後，我們只需要去計算核的積分或加總，如果不是 1，只要再除上一個歸一常數就可以了。</p>

<p>簡單來說，假設一個非負函數的 \(f(x)\) 的積分為 \(c\)，我們就可以定義 \(f(x)/c\) 為一個機率函數，因為：</p>

\[\int \frac{f(x)}{c} \, dx = \frac{1}{c} \int f(x) \, dx = \frac{c}{c} = 1\]

<p>而要辨認出機率函數的核也很簡單：只要跟隨機變數有關的部分都是核，而可獨立分離出來的乘數就是歸一常數。我們可以簡單來看幾個例子。如 Gamma 分配：</p>

\[\frac{\beta^{\alpha}}{\Gamma(\alpha)} \boxed{ x^{\alpha-1}e^{ -\beta x } }\]

<p>或 Beta 分配：</p>

\[\frac{\Gamma(\alpha) + \Gamma(\beta)}{\Gamma(\alpha+\beta)} \boxed{ x^{\alpha-1} (1-x)^{\beta-1}}\]

<p>或 Poisson 分配：</p>

\[\boxed{ \frac{1}{x!} \lambda^{x} } e^{ -\lambda }\]

<p>圖中方框內與 \(x\) 有關、無法分離的部分，就是這些機率函數各自的核，而方框外的部分就是與 \(x\) 無關的歸一常數。而我們也發現，不同形式的核，也自然地會對應不同但唯一的歸一常數形式。</p>

<p>知道核可以做什麼呢？如上所述，不同的核代表著不同的機率函數，而剩下的部分（也就是歸一常數）都相對「不重要」，因此做計算時，如果事先就知道我的計算結果是一個機率函數，我就可以先把所有與 \(x\) 無關的部分拋棄不算。我只要在計算的最後認得我的核的長相，我就可以輕鬆把歸一常數反推出來，省去中間大量計算不重要常數的時間。</p>

<p>機率函數的這種特性在貝氏統計進行機率更新時，被利用得淋漓盡致。但在真正開始機率更新前，讓我們再來看看概似函數（likelihood），如果已經有統計基礎的你，下一節可以直接跳過。</p>

<h2 id="什麼是概似函數">什麼是概似函數？</h2>

<p>概似函數和機率其實是同一個問題的兩面。機率關心的是「母體參數會如何影響實驗結果？」，而概似函數關心的是「有了實驗結果，我能怎麼推斷母體參數？」</p>

<p>拿最簡單的不公正硬幣實驗作為例子：某不公正硬幣出現正面的機率是 0.3，出現反面的機率是 0.7。此時，如果我擲硬幣 100 次，我會預期高機率出現約 30 次正面和 70 次反面，這就是機率；反過來說，如果我今天丟了某個不公正硬幣 100，觀察到 30 次正面和 70 次反面，我會猜測這個硬幣出現正面的機率很有可能是 0.3——這就是概似函數的核心。</p>

<p>如上所示，概似和機率是同一個問題的兩面，只是套用了不同的解讀方向，這點在概似函數的數學表現式上展露無遺：概似函數和機率密度函數長得一模一樣。</p>

<p>在獨立同分布（independent and identically distributed, \(i.i.d.\)）的實驗狀況下，\(n\) 個隨機變數的聯合機率分配，正是各自的機率密度函數乘積，也就是：</p>

\[p(x_{1}, x_{2},\dots, x_{n} \mid \theta) = p(x_{1} \mid \theta) \times p(x_{2} \mid \theta) \times \dots \times p(x_{n} \mid \theta) = \prod_{i=1}^{n} p(x_{i} \mid \theta)\]

<p>上面的公式可以被解讀為「給定參數 \(\theta\)，在進行多次實驗後，結果為 \(x_{1}, \dots, x_{n}\) 的機率為何？」。而其相對應的概似函數可以寫為下式：</p>

\[\mathcal{L(\theta ; x_{1}, \dots,x_{n})} = \prod_{i=1}^{n} p(x_{i} \mid \theta)\]

<p>沒錯，兩條公式壓根就長得一模一樣，只有一個地方變了——原本的機率函數是 \(x\) 的函數，現在成為了 \(\theta\) 的函數，也就是說，我不是在問給定 \(\theta\) 下，出現 \(x_1, \dots, x_n\) 的機率，而是「給定我看到的結果 \(x_1, \dots ,x_n\)，我的母體參數是 \(\theta\) 的機率為何？」我的目光焦點從實驗結果的機率轉向了母體參數的機率。</p>

<p>有了核、有了概似函數，我們就可以回到本次的主角——貝氏定理了。</p>

<h2 id="利用核概似函數以及貝氏定理進行機率更新">利用核、概似函數以及貝氏定理進行機率更新</h2>

<p>貝氏定理即為我們高中常見的：</p>

\[\mathbb{P} \left( A \mid B \right) = \frac{\mathbb{P} \left( B \mid A \right)  \times \mathbb{P} \left( A \right) }{\mathbb{P} \left( B \right) }\]

<p>這個定理貫穿了貝氏統計的機率更新手法，這也是貝氏統計得此名的原因。</p>

<p>具體而言，貝氏統計會先認定我的觀測數據是根據哪種機率分布出現（硬幣問題是伯努利分配、計數問題是 Poisson、身高是常態等等），並且想要推估這項分配的參數為何（如伯努利的機率 \(\theta\)，Poisson 的平均 \(\lambda\)，身高的平均 \(\mu\)），並且為這個參數選擇適合的先驗機率分配（至於何謂適合在這篇文章會提到）。</p>

<p>舉例而言，如果我想推論一個不公正硬幣出現正面的機率 \(\theta\)，我們已經知道硬幣實驗的結果會是參數為 \(\theta\) 伯努利分配（Bernoulli Distribution）：</p>

\[p(X; \theta) = \theta^{X}(1-\theta)^{1-X}\]

<p>我可以利用我的經驗、領域知識、以及信念為 \(\theta\) 選擇一個先驗 Beta 分配（上文中有 Beta 分配的長相。Beta 分配有兩個參數 \(\alpha\) 和 \(\beta\)，但不是現在的重點）。在我擲了 n 次硬幣，並觀察到結果後，我就可以獲得一個概似函數（likelihood）：</p>

\[p(x_{1},\dots, x_{n} \mid \theta) = \prod_{i=1}^{n} p(x_{i} \mid \theta) = \prod_{i=1}^{n} \theta^{x_{i}}(1-\theta)^{1-x_{i}} = \theta^{\sum_{i=1}^{n} x_{i}}(1-\theta)^{n-\sum_{i=1}^{n} x_{i}}\]

<p>到這裡，學過古典統計的你應該對這個 likelihood 十分面熟。古典統計會將這個函數取 log，並找到使 log-likelihood 最大的 \(\theta\)，也就是最大概似估計法 MLE。但這裡就是貝氏統計和古典統計分道揚鑣的地方。</p>

<p>在貝氏統計中，我們想要依據觀察到的結果，更新我的參數機率，形成後驗分配，而貝氏定理完美地派上用場：</p>

\[p(\theta \mid x_{1}, \dots, x_{n}) = \frac{p(x_{1},\dots,x_{n} \mid \theta) \times p(\theta)}{p(x_{1},\dots,x_{n})}\]

<p>我們驚喜地發現，等式右邊的分子正好就是我的 likelihood 以及先驗機率！雖然我不知道下方分母的全機率 \(p(x_1,\dots,x_n)\)，但由於它和我要推論的 \(\theta\) 沒有關係（特別注意，雖然 \(\theta\) 是 X 的參數，但我們這裡是想要推論 \(\theta\) 的機率分配，所以後驗機率中， \(\theta\) 是一個隨機變數，而 x1,…,xn 則變成了後驗機率中參數的一部分！），屬於歸一常數的一部分，我們可以將它捨去（反正我知道我要算的是機率，只要能夠認出核，我就可以推斷出歸一常數），因此等式變成：</p>

\[p(\theta \mid x_{1}, \dots, x_{n}) \propto p(x_{1},\dots,x_{n} \mid \theta) \times p(\theta)\]

<p>也就是後驗機率正比於先驗機率乘以 likelihood。這又是什麼呢？可以發現（一樣先無視歸一常數）：</p>

\[\begin{align}
p(x_{1},\dots,x_{n} \mid \theta) \times p(\theta) &amp;\propto \theta^{\sum_{i=1}^{n} x_{i}}(1-\theta)^{n-\sum_{i=1}^{n} x_{i}}\theta^{\alpha-1}(1-\theta)^{\beta-1} \\
&amp;= \theta^{\alpha + \sum_{i=1}^{n} x_{i} -1} (1-\theta)^{\beta-n+\sum_{i=1}^{n} x_{i} - 1}
\end{align}\]

<p>認出來了嗎？這串看似混亂的公式，正是一個 Beta 函數的核！我們把它寫得更乾淨一些：</p>

\[\begin{align}
\theta^{\alpha + \sum_{i=1}^{n} x_{i}} (1-\theta)^{\beta-n+\sum_{i=1}^{n} x_{i} - 1} = \theta^{\alpha_{n} - 1}(1-\theta)^{\beta_{n}-1} \\
\text{where} \quad \alpha_{n} = \alpha + \sum_{i=1}^{n} x_{i} \quad \text{and} \quad \beta_{n} = \beta-n+\sum_{i=1}^{n} x_{i}
\end{align}\]

<p>由於我們一開始要計算的後驗機率是一個機率函數，所以我們知道這個函數的加總必須為 1，但我們早就知道 Beta 函數的歸一常數長什麼樣子了，所以我們可以很輕鬆地把前面丟掉的常數用一個很乾淨的方式寫回來：</p>

\[p(\theta \mid x_{1},\dots,x_{n}) = \frac{\Gamma(\alpha_{n}) + \Gamma(\beta_{n})}{\Gamma(\alpha_{n}+\beta_{n})} \theta^{\alpha_{n}-1} (1-\theta)^{\beta_{n}-1}\]

<p>這樣我們就完成機率更新了！硬幣的後驗機率分配正好就是一個參數為 \(\alpha_{n}\) 和 \(\beta_{n}\) 的 Beta 分配。也就是，我們沒有改變分配的長相，只是更新的參數的大小。</p>

<p>哪有這麼好的事，先驗分配跟概似兩個八竿子打不著的機率相乘後，竟然得到一個乾乾淨淨，還長得跟先驗一樣的後驗分配？這當然不是巧合，而是在選擇先驗時就已經精打細算的設計。要能夠有這麼漂亮的結果，我們就得依照 likelihood 的長相，選擇一個合適的共軛先驗，我們會在下一篇文章中進一步討論。</p>

<h2 id="結論">結論</h2>

<p>簡而言之，貝氏統計利用機率函數都有唯一的核以及歸一常數的特性，在機率更新時無視歸一常數，專注於核的計算，並在最後透過「認出」核，將歸一常數反推回來，完成簡單快速的機率更新。</p>

<p>當然，並不是每次的機率更新都可以這麼輕鬆寫意。更常出現的情況，是我們認不得後驗機率的核（又或者，那個核沒有常見、已被命名、有良好性質的對應機率函數）。上面的情況中的機率函數如果在硬算後有封閉解那還算好事，頂多就是計算麻煩了點，但有時我們甚至沒有辦法得知歸一常數長怎麼樣，也甚至無法對整個核積分，也就是我們甚至沒辦法得到漂亮的、加總為一的機率函數！這時候我們就要依靠更多後期發展出來的工具，如蒙地卡羅方法，來幫助我們估計後驗分配。</p>

<p>因此，這種方便的後驗機率更新方法只能說是貝氏統計中一種驚喜的特例，是由精明的統計學家找到的特殊解方，但卻不影響它的實用價值以及對於整個貝氏統計理論的奠基。</p>

<script id="MathJax-script" async="" src="https://cdn.jsdelivr.net/npm/mathjax@4/tex-mml-chtml.js"></script>]]></content><author><name></name></author><summary type="html"><![CDATA[上一篇文章中我試著用直覺的方式介紹了貝氏統計的哲學：先驗機率以及機率更新。這篇文章將會深入數學的部分，介紹貝氏統計如何善用機率函數的特性以及鼎鼎大名的貝氏定理，從先驗機率和概似函數的計算中，用簡單的方法推算出後驗機率。]]></summary></entry><entry><title type="html">用直覺理解貝式統計：一個不用數學的入門指南</title><link href="https://kckhchen.com/blog/bayesian/" rel="alternate" type="text/html" title="用直覺理解貝式統計：一個不用數學的入門指南" /><published>2025-05-19T00:00:00+00:00</published><updated>2025-05-19T00:00:00+00:00</updated><id>https://kckhchen.com/blog/bayesian</id><content type="html" xml:base="https://kckhchen.com/blog/bayesian/"><![CDATA[<p>這篇文章中，我會試圖用淺白的語言一步步帶你認識貝氏統計學派的思想世界。我會從一個高中常見的統計問題「球袋問題」出發，並講述貝氏學派的兩大特色：先驗機率以及後驗分佈，最後帶你看看一個貝氏統計學家，可能會從什麼新的角度詮釋這個經典的球袋問題。這是一篇重在概念介紹的文章，因此不會有任何的數學推論和公式。</p>

<h2 id="從古典統計學出發球袋的比喻">從古典統計學出發：球袋的比喻</h2>

<p>你的眼前有一個不透明的球袋，我們知道裡面有若干顆球，球的顏色不是紅色就是白色。在一次只能拿出一顆球，並且看完必須放回袋中的情況下，我們要如何才能推斷這個球袋中紅球與白球的比例？</p>

<p>這題簡單。我們每次從袋中隨機取出一顆球，紀錄顏色後放回，並再次取球。如此重複多次後，我們便可以大致推斷出紅球和白球各自出現的<strong>頻率</strong>。如果在 1,000 次抽樣後，有 600 次出現紅球，400 次出現白球，我們便可以十分有自信地推斷袋中的紅白球比約為 6:4，又或者，紅球和白球出現的機率分別為 0.6 和 0.4。</p>

<p>熟悉統計學概論的你可能會知道，這種做法的理論基礎（之一）即是最大概似估計式（maximum likelihood estimator, MLE）。簡而言之，我們在抽出 600 次紅球和 400 次白球後，推論「袋中紅白球比例是 6:4 的情況」最有可能造成我們的實驗結果。假設袋中的紅白球比不是 6:4（例如 7:3 或 9:1），則不太可能導致我們現在出現的實驗結果。這個就是最大概似估計法的精神。並且我們知道，重複進行抽樣的次數越多，我們的估計就可能越精準，越接近正確的比例（也就是統計學上的「信賴區間」越小）。</p>

<p>問題來了。如果今天我們被限制，總共只能進行 10 次抽樣呢？或是 5 次？甚至只有 1 次？如果今天我們只能抽取一次，並且抽出了白球，我們能依照這個實驗結果對袋中的紅白球比例進行什麼推論呢？</p>

<p>上面這個難題，成為了古典統計學（或稱頻率學派）的硬傷。顧名思義，頻率學派的推論哲學，即是相信大數法則（Law of Large Number, LLN），相信只要進行足夠多次的抽樣試驗，最終實驗中某事件出現的<strong>頻率</strong>會趨近該事件發生的<strong>機率</strong>。這時，在抽樣次數不足的情況下，古典統計的估計方法可能不穩定，甚至出現偏差較大的結果。舉上面的例子而言，如果我們在面對只有一次抽樣機會，並且抽出白球的情況下，透過 MLE 計算得出的紅白球比例是——0:1。也就是袋中全部都是白球（當然，估計的信賴區間會大到這個數值幾乎沒有實用意義）。</p>

<p>袋中全是白球的狀況固然不是沒有可能，但不太符合<strong>直覺</strong>對吧？如果我說，有一種統計方法能在這種情況下，有機會給出比較好的推論呢？今天介紹的主角「貝氏統計」就能做到，而它做到的方法，便是要你相信你的直覺。</p>

<h2 id="尊重直覺的貝式統計">尊重直覺的貝式統計</h2>

<p>投擲一枚（不必然公正）硬幣，出現正面的機率是多少？投擲一顆（不必然公正的）骰子，出現三的機率是多少？如果你的回答是「沒丟過不知道」，恭喜你，你是一名天生的頻率學派。如果你的回答是「二分之一⋯⋯？」和「六分之一⋯⋯？」那恭喜你，你是一名貝氏學派統計學家。怎麼說呢？</p>

<p>如前言所述，頻率學派的統計學家認為對於母體參數（也就是真正的機率）最理性的估計方法就是透過實驗和數學推論，並且屏除人類直覺的主觀干擾。乍聽之下非常合理，頻率學派的推論方法也的確在統計領域數百年來屹立不搖，直到現今都還十分受歡迎。但假設今天碰到前言的情況，只能進行一次實驗呢？這時你要相信一個孤立無援的抽樣結果，還是自己的直覺？</p>

<p>在另一方面，十八世紀橫空出世的貝氏統計則採取了另一個方法。</p>

<p>貝氏學派肯定<strong>信念</strong>的重要性。信念也就是在你做任何實驗前，根據經驗、直覺、常識，甚至盲目猜測的<strong>主觀機率</strong>。這個機率在貝氏統計的術語中稱為先驗機率（或先驗分布，prior distribution）。舉不必然公正硬幣的例子而言，如果我們在實驗前猜測，出現正面的機率很有可能是 1/2，並且認為硬幣不太可能非常不公正（例如正面或反面的機率為 1），我們可能會依據這樣的信念，將硬幣出現正面的先驗機率表示成下面這張圖<sup id="fnref:beta" role="doc-noteref"><a href="#fn:beta" class="footnote" rel="footnote">1</a></sup>：</p>

<p><img src="/blog/assets/images/beta55.png" alt="" /></p>

<p>這是典型的機率密度函數（probability density distribution, pdf），x 軸為硬幣出現正面的機率，y 軸可以簡單理解為「可能性」。可以發現函數圖形在 x=0.5 時達到最高，這也就代表我們在實驗前，猜測硬幣出現正面機率為 0.5 的機率最大，且硬幣出現正面的機率不太可能非常高（x=0.9）或非常低（x=0.1）。這種對於連帶參數的不確定性也納入考量的方法，也是貝氏統計另一個非常重要的性質，將會在下一節提到。</p>

<p>有了直覺作為基底後，我們便可以開始進行實驗。假設我們投擲了三次硬幣，竟然連續三次都出現反面，此時我們會透過<strong>貝氏定理</strong>（也就是高中數學課出現過的那條公式，至於如何使用不是本文的討論範圍）對我們的先驗機率進行更新，重新繪製一幅機率密度函數，或許會長成這樣：</p>

<p><img src="/blog/assets/images/beta58.png" alt="" /></p>

<p>這個更新過後的機率，就被我們稱為後驗機率（posterior distribution）。可以發現，現在函數圖形的最高點在 0.4 上下，也就是說我們傾向相信這個硬幣可能是不公正的，而且出現正面的機率可能為約 0.4。值得注意的是，如果同樣的實驗結果（連續三次反面）由頻率學派解讀，可能會不得不承認，這個硬幣出現正面的機率是 0（一樣，信賴區間會大到使得統計值沒有實用意義），畢竟在有限次的實驗之中，沒有任何一次出現過正面。此時貝氏學派的優勢就很明顯了。</p>

<p>我們可以繼續進行實驗，此時剛才的後驗機率成為了現在的先驗機率，也就是我們透過<strong>經驗累積修正了我們的信念</strong>，並且繼續透過更多的實驗對機率進行更新。如果在 1,000 次實驗過後，僅出現 100 次正面，卻有 900 次反面，我們更新後的後驗機率則會像這樣：</p>

<p><img src="/blog/assets/images/beta105905.png" alt="" /></p>

<p>此時機率最高的地方出現在 \(x=0.1\) 上下。經過這一連串的機率修正，我們發現貝氏學派的核心宗旨正是試錯（trial and error），並且面對實驗結果不停更新機率，並在我們的信念和實驗結果間求取一個平衡。</p>

<p>貝氏統計在此的優勢盡顯：在實驗次數有限的情況下，我們追求先驗的信念和實驗結果間的平衡，使得推論出來的機率不會太偏頗；隨著實驗次數增加，我們獲得越來越多資訊後，初始信念的權重在一連串的更新下自然而然地下降，實驗結果的重要性上升，並且會在<strong>最後趨近頻率學派的估計</strong>。也就是說，貝氏統計進可攻，退可守，在實驗次數少時透過<strong>偏重信念</strong>，穩定機率推論，在實驗次數多時<strong>偏重數據</strong>，估計準確度上也不輸頻率學派。</p>

<p>至於先驗機率，或是信念，該怎麼挑選呢？這牽涉到非常深入的貝氏統計理論，不過簡而言之，通常我們可以透過專家推論（氣象學家認為的颱風登陸機率）、經驗法則（過去半年來觀察到的晶片良率），或單純的「我不知道」（認為硬幣出現正面的機率從 0%-100% 可能性一樣高），這些都有機會成為良好的先驗機率。</p>

<h2 id="擁抱不確定性的貝氏統計">擁抱不確定性的貝氏統計</h2>

<p>從上一節的討論中，細心的你可能已經注意到貝氏學派和頻率學派的另一個差異。頻率學派會告訴你他們推論的<strong>機率值</strong>，但貝氏學派只會給你<strong>一張圖</strong>，也就是密度函數（也就是上一節提到的「機率的機率」）。這是貝氏統計的一大特點：將母體參數的估計視為變數。</p>

<p>頻率學派的哲學相信，任何事物發生的機率都有一套固定、真實的值（袋中的紅白球比一定有一個答案、明天下雨的機率一定有一個正確的數值等等）。從有限的實驗次數中觀察，並推論出那個真正的答案（母體參數）便自然而言地成為了頻率學派的目標。也因此，頻率學派的推論方法最終都回到三個估計法：點估計（找到確定值）、區間估計（找到包含確定值的範圍）、假設檢定（驗證我的值猜測準不準確）。一言以蔽之，頻率學派相信母體參數是一個不會變的<strong>常數</strong>，所以我們要找到一個常數來估計它。</p>

<p>另一方面，貝氏學派選擇另一種態度：無論母體參數存在與否，在沒有母體下，我們都承認不可能找到正確答案，所以就連最不可能的答案我們都不排除，因此，估計的結果應該是一個<strong>變數</strong>。</p>

<p>回到上方擲硬幣的例子，在出現 100 次正面和 900 次反面後，頻率學派可能會跟你說：「我們有 90/95/99/…% 的信心，硬幣出現正面的<strong>那個正確機率</strong>大概落在 0.1 上下<sup id="fnref:ci" role="doc-noteref"><a href="#fn:ci" class="footnote" rel="footnote">2</a></sup>」。</p>

<p>但貝氏學派只會再一次給出這張圖：</p>

<p><img src="/blog/assets/images/beta105905.png" alt="" /></p>

<p>並且兩手一攤，告訴你：「我們沒辦法真的知道硬幣出現正面的機率，不過在實驗過後，我們認為硬幣是正面的機率，有很高的機率是 0.1。但也有一點可能性是 0.13。另外，雖然可能性不高，但我們也不排除出現正面的機率是 0.5，說不定我們只是運氣很糟，一直丟到反面而已。什麼事都有可能發生，對吧？」想從一個堅實的貝氏統計學家口中套出<strong>那個真正機率</strong>非常困難，他永遠會告訴你「我們不排除所有可能」，並且給你一張機率函數圖形，因為它呈現了我們對不確定性的完整認知。</p>

<p>我們能不能從機率密度函數圖形中找出<strong>那個答案</strong>？也不是不行。如果我們非得要寫出一個數字，我們可能會選擇這個函數圖形的平均值或是眾數（函數圖形最高的那一點），但從一個貝氏統計學家的視角出發，沒有一個點能夠比起整個圖形，給出更完整、全面的資訊。</p>

<p>這或許是貝氏統計最令人費解，卻也最迷人之處。許多人初見貝氏統計時，可能是驚訝大過於仰慕：「所以<strong>那個答案</strong>不存在？」但貝氏統計想說的其實是：「除非你有母體資料，否則你永遠不可能知道真正的答案，我們的的推論只是在反映<strong>我們不知道</strong>的這個事實而已。」有人可能會批評貝氏統計的推論方法不負責任、逃避問題，但也有人認為這方法正是勇敢的表現：勇於承認我們的無知，不執意追求我們無法得知的<strong>那個答案</strong>，保持謙卑，並正面擁抱不確定性。</p>

<h2 id="結論">結論</h2>

<p>正是這兩個推翻傳統統計哲學的新思想，讓貝氏學派與眾不同，在發展至今成為和頻率學派得以分庭抗禮的統計學派。貝氏統計從稱之為信念的先驗機率出發，在實驗過程中逐步更新、修正，導出後驗機率。並且，貝氏統計傾向將母體參數視為一個變數，而非一個常數。</p>

<p>你可能會問，現實中有什麼狀況是不能透過大量重複實驗推論得知的？其實還真不少：最新落成的核電廠發生核洩漏意外的機率、台灣在下一屆棒球經典賽打入四強的機率、某一位罕病人士在五年內的存活率等等，這些都不是可以大量複製的實驗。而貝氏統計的強項之一便在於此。我們可以先透過對核電廠周圍環境、球隊陣容與球員能力、疾病的生理特性等背景資訊，推論出先驗機率。接著再透過少量的觀察資料進行更新，便能產生有意義的統計推論。貝氏的使用情境不僅於此，還有許多情境中，貝氏統計意外地可以給出比起頻率學派更有幫助的答案。</p>

<p>當然，即便兩個學派的哲學相去甚遠，這並不代表我們必須要選邊站。在多數情況下，貝氏學派和頻率學派的理論學說相輔相成，學術界更常同時參採兩方的長處進行更完整的推論。</p>

<h2 id="回到球袋的問題">回到球袋的問題</h2>

<p>回到一開始的球袋問題，一個貝氏統計學家會怎麼解讀只能抽取一次，並抽到白球的狀況呢？</p>

<p>我們會先定義一個先驗機率。例如，如果我們沒有足夠的理由相信袋中紅白球的比例為多少，或許我們可以先設定這樣的先驗機率<sup id="fnref:unif" role="doc-noteref"><a href="#fn:unif" class="footnote" rel="footnote">3</a></sup>：</p>

<p><img src="/blog/assets/images/unif.png" alt="" /></p>

<p>也就是，我們認為袋中紅白球比例無論是多少，都有一樣的可能性。可能是 1:9，可能是 2:8，可能是 3.1415:6:8385。我們對於每個比例的可能性都採取相同的開放態度（當然，你可以依據其他的理由選擇不同的先驗機率，這當然也會導致不同的後驗機率，不過在此只以其中一種常見的作為範例）。</p>

<p>在單次試驗並抽出白球後，我們便會對我們的信念做出修正，計算出後驗機率：</p>

<p><img src="/blog/assets/images/beta12.png" alt="" /></p>

<p>這時，貝氏統計學家可以這樣回答這個問題：「我們不知道袋中的紅白球比例，但是我們認為紅白球比例為 1:9 （含以下）的機率大約是 0.2（圖中著色部分），同時我們不排除其他可能性，在有更多次實驗後，我們才可以給出更有自信的答案。」</p>

<p>不知道這樣的說法是否有說服你？不知道你是否覺得這比起頻率學派的「比例是 0:1，但我超級不確定，以至於這個推論幾乎沒有參考價值」更能給出較為全面、有意義的答案？無論你是否有被貝氏統計說服，希望這篇文章成功提供給你一種全新觀看統計推論的視角。</p>

<p>最後的最後，讓我們用一張美妙的 GIF 結束整篇文章，動態呈現貝氏統計如何從一個不帶資訊的先驗機率（藍線）開始，透過後驗機率修正，逼近一個紅白球比為 2:8（紅線）的球袋實驗吧。</p>

<p><img src="/blog/assets/images/abc-animation.gif" alt="" width="550" /></p>

<hr />

<h2 id="註解">註解</h2>

<script id="MathJax-script" async="" src="https://cdn.jsdelivr.net/npm/mathjax@4/tex-mml-chtml.js"></script>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:beta" role="doc-endnote">
      <p>熟悉機率論的你可能知道，這裡使用的是 Beta(5,5) 的機率分布圖形。事實上，整篇文章的機率函數圖都是不同參數的 Beta 分佈。 <a href="#fnref:beta" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:ci" role="doc-endnote">
      <p>這裡其實在不嚴謹的情況下使用了信賴區間的概念，如果有堅實統計背景的人可能會覺得怪怪的，不過因為古典統計學不是這次討論的重點，因此恕我這樣潦草地帶過。 <a href="#fnref:ci" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:unif" role="doc-endnote">
      <p>其實在這樣的先驗機率下做後驗機率修正，並求取最大後驗機率（maximum a posterior, MAP）等價於直接求取 MLE，但如文中所述，MAP 這種單一估計值的觀念其實稍微背離了貝氏的哲學，通常作為輔助決策用。 <a href="#fnref:unif" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name></name></author><summary type="html"><![CDATA[這篇文章中，我會試圖用淺白的語言一步步帶你認識貝氏統計學派的思想世界。我會從一個高中常見的統計問題「球袋問題」出發，並講述貝氏學派的兩大特色：先驗機率以及後驗分佈，最後帶你看看一個貝氏統計學家，可能會從什麼新的角度詮釋這個經典的球袋問題。這是一篇重在概念介紹的文章，因此不會有任何的數學推論和公式。]]></summary></entry><entry><title type="html">理解樣本變異數與自由度（一）：自由度</title><link href="https://kckhchen.com/blog/freedom-1/" rel="alternate" type="text/html" title="理解樣本變異數與自由度（一）：自由度" /><published>2025-01-26T00:00:00+00:00</published><updated>2025-01-26T00:00:00+00:00</updated><id>https://kckhchen.com/blog/freedom-1</id><content type="html" xml:base="https://kckhchen.com/blog/freedom-1/"><![CDATA[<p>本篇文章將會用非常直覺、深入淺出的方式，從樣本變異數的公式出發，讓不熟悉統計的人或是統計初學者對統計中無所不在卻又神秘的術語「自由度（Degree of Freedom）」有基本的理解，並且從多個面向解釋這個許多人常常一知半解的統計疑問：</p>

<blockquote>
  <p>到底為什麼樣本變異數的公式，分母是 n-1？</p>
</blockquote>

<p>文章會以如下的架構進行：整部文章將會分為三篇。我會先帶你直覺理解什麼是自由度，並會告訴你樣本變異數為何要使用 n-1 的自由度（只用 n 的話會如何？用 n-1 又有什麼好處？）。我會用十分直覺的方式帶你理解使用（或不使用） n-1 導致的效果。最後，我會用專業統計的語言給你更全面的解釋。</p>

<p>我盡力將整篇文章由淺入深，由直覺到數學，一步一步帶領你理解自由度和樣本變異數。這樣的好處還有一個：你不必然要將整篇文章從頭到尾看完。你可以依據你專業上或興趣上的需要，看到你滿意的地方為止，無需太過於深入統計細節，也不會被滿滿的數學嚇壞。</p>

<div class="callout callout-warning">
  <div class="callout-title"><i class="callout-icon" data-lucide="circle-alert"></i><span class="callout-title-text">警告</span></div>
  <p>這篇文章不是專業嚴謹的定義解釋，而是我融合所學與經驗發想出，希望以更生活化、更少數學定義的方法理解統計語言的文章（簡單來說，專業統計人看到可能會高血壓的文章）。如果你更想看到專業、不含糊、數學定義正確的文章，這並不是這篇文章的主要目的，網路上有更多更好的文章）</p>
</div>
<h2 id="開始之前">開始之前</h2>

<p>最一開始，我們先來看一下母體變異數（\(\sigma^{2}\)）與樣本變異數（\(S^{2}\)）的公式（對於公式熟悉的你可以直接挑過這段）</p>

<p>母體變異數：</p>

\[\sigma^{2} = \frac{1}{n} \sum_{i = 1}^{n}(x_{i} - \mu)^{2}\]

<p>其中 n 為樣本數，\(\mu\) 為母體平均值（或稱期望值）。</p>

<p>另一方面，樣本變異數的長相為：</p>

\[S^{2} = \frac{1}{n-1}\sum_{i = 1}^{n} (x_{i} - \bar{x})^{2}\]

<p>其中 \(\bar{x}\) 為樣本平均值，公式為 \(\bar{x} = \frac{1}{n}\sum_{i = 1}^{n} x_{i}\) 。在不知道母體變異數和母體平均值的情況下，我們就可以利用樣本的平均值和變異數來估算母體的變異數。</p>

<h2 id="直覺理解自由度">直覺理解自由度</h2>

<p>所以為什麼樣本變異數的分母中，我們的樣本數硬生生少了 1？在許多統計學的課堂上，或許我們都被要求「背下來就好」，或是用一句「分母寫的是自由度」帶過，但究竟什麼是自由度？</p>

<p>我們在高中數學習題中，一定有做過一種題目（這題數學很簡單拜託不要關掉分頁）：</p>

<blockquote>
  <p>小明紀錄了自己五天的看書頁數，並計算出平均值為 15 頁。有天他意外將桌上墨水打翻，五染了第五天的數據。已知小明前四天看書的頁數分別為 20、16、14、15 頁，請問小明第五天看了幾頁？</p>
</blockquote>

<p>我們可以帶入平均值的公式 \((20 + 16 + 14 + 15 + x) / 5 = 15\)，並且算出第五天被污染的數字正是 x = 10 頁。但這和自由度有什麼關係？</p>

<p>想像一下我們進行抽樣的場景。假設我們打算抽出五個數據，在這五個數據被抽出前，它們的狀態都是「自由」的：在我抽出來之前，我完全無法猜到下個數據會是多少，最後抽出來的數值可能極高，也可能極低，但我無從得知。我們稱這種自由的狀態為「自由度」。因此如果我打算抽出五筆數據，這個抽樣就有五個自由度。</p>

<p>現在想像另一種情況：我抽出五個數據，但我在抽出數據前，就莫名得知了這五個數據的平均值為 15。當然，在抽出前四筆數據時，我依然不知道我會抽出什麼數值，這四個數據仍然是「自由」的。假設我分別抽出了 20、16、14、15 四個數字。現在的我，在知道平均值的情況下，就算不用抽也知道第五個出現的數值會是 10，因為只有 10 才可以讓我整個樣本的平均值變成 15（如果你還沒發現，這裡的數字跟上面的題目一樣）。</p>

<p>也就是說，在我抽出四個數據，並且知道樣本平均數的情況下，第五個尚未被抽出的數據其實已經沒有自由可言了。這時，我們的樣本就損失了一個自由度。一言以蔽之，如果我們知道大小為 n 的樣本的平均值，在我們得知 n-1 個數據的實現值後，最後一個數據可以直接被我用平均值推算出來，這個數據的實現值對我而言已經沒有隨機性（自由！）了。</p>

<p>有些人可能會將自由度理解為「給定某些統計量，完全復原樣本所需的最少數據數量」，就像一開始的高中題目，小明能夠復原第五天閱讀的頁數一樣。這是一個不太精確的說法，但以這篇文章而言，這樣的理解也夠用了。</p>

<p>最後，用上數學的話來說，自由度就是在估計時，樣本中<strong>獨立</strong>且能夠<strong>自由變化</strong>的觀測值數量。</p>

<p>回到我們最一開始，樣本變異數的公式：</p>

\[S^{2} = \frac{1}{n-1}\sum_{i = 1}^{n} (x_{i} - \bar{x})^{2}\]

<p>仔細一看，會發現括號當中有一個 \(\bar{x}\)（樣本平均值）存在！也就是說，在估算樣本變異數時，我們已經用上了一個被我們當作已知的資訊（也就是平均值），在這條公式當中，給定平均值後，已經有一筆數據默默地失去了自由變動的權利，因此我們也損失了一個自由度。這也是為什麼我們在分母會寫上 n-1 了。</p>

<p>看到這裡，可能很多人還是不滿意。好，我大概知道自由度是什麼了，但為什麼分母必須要寫上自由度？我改用樣本數會怎麼樣嗎？</p>

<p>下一篇文章，我會針對這個問題盡量給出一個簡單直覺的解釋。</p>

<h2 id="文章總結">文章總結</h2>

<ol>
  <li>自由度描述的是樣本中獨立的隨機（自由）變化數值的數量</li>
  <li>當我們得知樣本平均值後，只需要 n-1 筆數據，就能推算最後一筆數據的值</li>
  <li>我們在計算樣本變異數的時候，用上了樣本平均值。給定了樣本平均值後，我的樣本中可以自由變化的數據其實只剩下 n-1 筆，因此分母必須隨之調整。</li>
</ol>

<p>（如果你只是想知道自由度是什麽，看到這裡就可以結束了，希望能滿足你的小小好奇心。下一章之後我僅會單純就變異數的分母問題進行深入討論。）</p>

<p>下一章：<a href="/blog/freedom-2/">第二章</a></p>

<!-- Obsidian Callout Styles - Generated by Obsidian2Jekyll -->
<style>
  .callout {
    padding: 10px 24px 2px;
    margin: 1.5em 0;
    border-radius: 4px;
    background-color: rgba(200, 200, 200, 0.2);
    --accent-clr: #9c9c9c;
  }

  .callout-icon {
    width: 1rem;
    margin-right: 0.5rem;
  }

  .callout-title {
    width: 80%;
    font-weight: bold;
    margin-bottom: 8px;
    display: flex;
    align-items: center;
    color: var(--accent-clr);
  }

  .callout-info,
  .callout-todo,
  .callout-note {
    background-color: rgba(160, 200, 255, 0.2);
    --accent-clr: #5d92ee;
    border-left-color: var(--accent-clr);
  }

  .callout-abstract,
  .callout-summary,
  .callout-tldr,
  .callout-tip,
  .callout-hint,
  .callout-important {
    background-color: rgba(155, 255, 235, 0.2);
    --accent-clr: #53d2d0;
    border-left-color: var(--accent-clr);
  }

  .callout-success,
  .callout-check,
  .callout-done {
    background-color: rgba(140, 255, 180, 0.2);
    --accent-clr: #37b94e;
    border-left-color: var(--accent-clr);
  }

  .callout-warning,
  .callout-question,
  .callout-help,
  .callout-faq,
  .callout-caution,
  .callout-attention {
    background-color: rgba(245, 190, 160, 0.2);
    --accent-clr: #ec7501;
    border-left-color: var(--accent-clr);
  }

  .callout-danger,
  .callout-error,
  .callout-bug,
  .callout-fail,
  .callout-failure,
  .callout-missing {
    background-color: rgba(255, 200, 205, 0.2);
    --accent-clr: #ea3d51;
    border-left-color: var(--accent-clr);
  }

  .callout-example {
    background-color: rgba(215, 195, 250, 0.2);
    --accent-clr: #a181ff;
    border-left-color: var(--accent-clr);
  }

  .callout-quote,
  .callout-cite {
    background-color: rgba(220, 220, 220, 0.2);
    --accent-clr: #ababab;
    border-left-color: var(--accent-clr);
  }

  details > summary:first-of-type::after {
    content: '▶';
    display: inline-block;
    position: relative;
    right: -1em;
    transition: transform 0.2s ease;
  }

  details[open] > summary:first-of-type::after {
    transform: rotate(90deg);
  }
</style>

<!-- Lucide CDN -->
<script src="https://unpkg.com/lucide@latest"></script>

<script>
  lucide.createIcons({
    attrs: {
      'stroke-width': 2.5,
      stroke: 'currentColor',
    },
  });
</script>

<script id="MathJax-script" async="" src="https://cdn.jsdelivr.net/npm/mathjax@4/tex-mml-chtml.js"></script>]]></content><author><name></name></author><summary type="html"><![CDATA[本篇文章將會用非常直覺、深入淺出的方式，從樣本變異數的公式出發，讓不熟悉統計的人或是統計初學者對統計中無所不在卻又神秘的術語「自由度（Degree of Freedom）」有基本的理解，並且從多個面向解釋這個許多人常常一知半解的統計疑問：]]></summary></entry></feed>