Imperative กับ Declarative

Share
Imperative กับ Declarative
Photo by Brands&People / Unsplash

ในโลกของการเขียนโปรแกรม มันจะมีคำว่า imperative กับ declarative มันคืออะไรแล้วมันทำให้การเขียนโปรแกรมของเราเปลี่ยนไปยังไง มาลองถอดบทเรียนกัน


เริ่มจากแปลตรงๆ

  • Imperative (How): ต้องสั่งทีละขั้นตอนว่าต้องทำอย่างไร (เหมือนการบอกทางแบบละเอียด: เลี้ยวซ้าย 100 เมตร, เลี้ยวขวา...)
  • Declarative (What): บอกแค่ว่าผลลัพธ์ที่ต้องการคืออะไร (เหมือนการบอกปลายทาง: "ไปสยามพารากอน" แล้วให้ระบบจัดการเส้นทางเอง)

ขอบคุณ Gemini สำหรับคำแปล อิอิ

ตอนที่ผมเริ่มหัดเขียนโปรแกรม ก็เริ่มจากแบบ imperative นั่นแหละ มันก็เป็นการเรียกคำสั่งแบบง่ายๆ ตรงไปตรงมา เช่น บวกเลข หรือ พิมพ์ข้อความออกทางหน้าจอ เป็นต้น เป็นอย่างนั้นมานาน แล้วก็ไม่รู้ด้วยว่ามันมีการเขียนโปรแกรมแบบอื่นอยู่อีก แล้วผมก็เชื่อว่าน่าจะมีอีกหลายคนเป็นแบบเดียวกัน

ตัวอย่างการเขียนโปรแกรมแบบ Imperative ก่อน ตัวอย่างเช่นเราจะทำ web แสดงข้อมูลในรูปแบบตารางสักหนึ่งหน้า ถ้า เขียนแบบ imperative code ก็จะมีหน้าตาประมาณนี้

<div id="table-container">
  <table>
    <thrad>
      <tr>
        <th>Id</th><th>Name</th><th>Email</th>
      </tr>
    </thrad>
    <tbody></tbody>
  </table>
</div>

แล้วก็เขียน javascript ให้มันช่วยวาดตาราง ตามข้อมูล

const users = [
  { id: 1, name: "Somsak", email: "somsak@example.com" },
  { id: 2, name: "Somchai", email: "somchai@example.com" },
  { id: 3, name: "Somsri", email: "somsri@example.com" },
];

const appendCell = (row, element, content) => {
  element.textContent = content;
  row.appendChild(element);
};

document.addEventListener("DOMContentLoaded", () => {
  const tbody = const tbody = document.querySelector("#table-container tbody");

  users.forEach((user) => {
    const tr = document.createElement("tr");
    appendCell(tr, document.createElement("td"), user.id);
    appendCell(tr, document.createElement("td"), user.name);
    appendCell(tr, document.createElement("td"), user.email);
    tbody.appendChild(tr);
  });
});

Code สำหรับ render ตารางแบบ imperative

มันก็เป็นการเขียนแบบปกติ ไม่มีได้มีอะไรซับซ้อน เป็น code จัดการกับ dom ธรรมดาๆใช่ไหมครับ ที่ code ทำก็แค่ หาของในหน้าจอ แล้วก็ใส่ content เข้าไปให้ถูก

ที่นี่มาดูแบบ declarative กันบ้าง อันนี้ต้องขอใช้ Vue เพื่อความง่าย (แต่จะเขียนแบบ Vanilla ก็ได้นะครับ ลองถาม AI ดูว่าเบื้องหลัง web framework ที่ฮิตๆกันตอนนี้มีเบื้องหลังอย่างไร ได้ความรู้เพียบครับ)

วิธีการก็คือเตรียม UI ที่สมบูรณ์ไว้เลย แต่เจาะช่องไว้ รอเอาข้อมูลมาใส่

<table>
  <thead>
    <tr>
      <th v-for="header in headers" :key="header">{{ header }}</th>
    </tr>
  </thead>
  <tbody>
    <tr v-for="user in users" :key="user.id">
      <td>{{ user.id }}</td>
      <td>{{ user.name }}</td>
      <td>{{ user.email }}</td>
    </tr>
  </tbody>
</table>

table สำหรับ declarative

ในส่วนของ Javascript แค่เตรียมข้อมูลก็พอ

const headers = ["Id", "Name", "Email"];
const users = [
  { id: 1, name: "Somsak", email: "somsak@example.com" },
  { id: 2, name: "Somchai", email: "somchai@example.com" },
  { id: 3, name: "Somsri", email: "somsri@example.com" },
];
Vue.createApp({
  data() {
    return {
      headers,
      users,
    };
  },
}).mount("#table-container");

ลองเปรียบเทียบกัน มีอะไรที่ต่างไปบ้าง เช่น

  • declarative เห็น html ทั้งหมดตั้งแต่แรก เงื่อนไข หรือ for loop ก็อยู่ที่ html นั่นแหละ แต่ imperative คำสั่งสำหรับสร้างตารางมันกระจายไปอยู่ตาม function
  • Javascript code แทบไม่ได้ทำอะไร ทำแค่ init ข้อมูลเริ่มต้นอย่างเดียว

อาจจะยังไม่ชัดเท่าไหร่ เราลองเพิ่มฟีเจอร์เข้าไป เช่น การลบ เราจะเพิ่มปุ่มลบเข้าไป

แบบ imperative เราจะต้องสั่งทุกขั้นตอนเอง เช่น เพิ่มปุ่มเข้าไปใน cell , เอา cell แปะเข้าไปในตาราง และตอนกดปุ่ม ให้ลบข้อมูลใน array ก่อน แล้วก็สั่งให้ลบแถว

const appendActionCell = (row, userId) => {
    const actionCell = document.createElement("td");
    const deleteButton = document.createElement("button");
    deleteButton.textContent = "Delete";
    deleteButton.addEventListener("click", () => {
        users = users.filter((u) => u.id !== user.id);
        row.remove();
    });
    actionCell.appendChild(deleteButton);
    row.appendChild(actionCell);
}

เพิ่มปุ่มลบแบบ imperative

แต่แบบ declarative เราแยก concern เกี่ยวกับการ render ไปอยู่ใน html ดังนั้นจะสร้างปุ่มรอไว้ก่อนแล้ว แค่ใส่ event ให้ถูกว่าไปเรียก function ไหนก็พอ

<tbody>
  <tr v-for="user in users" :key="user.id">
    <td>{{ user.id }}</td>
    <td>{{ user.name }}</td>
    <td>{{ user.email }}</td>
    <td>
      <button @click="deleteUser(user.id)">Delete</button>
    </td>
  </tr>
</tbody>

ปุ่มลบแบบ declarative

พอจะลบ เราก็แค่จัดการกับข้อมูล อย่างเดียว พอ Vue มันพบว่าข้อมูล (หรือ state) มีการเปลี่ยนแปลง มันก็จัดการวาดใหม่ให้เราเอง

const headers = ["Id", "Name", "Email", "Action"];
let users = [
  { id: 1, name: "Somsak", email: "somsak@example.com" },
  { id: 2, name: "Somchai", email: "somchai@example.com" },
  { id: 3, name: "Somsri", email: "somsri@example.com" },
];
Vue.createApp({
  data() {
    return {
      headers,
      users,
    };
  },
  methods: {
    deleteUser(id) {
      this.users = this.users.filter((user) => user.id !== id);
    },
  },
}).mount("#table-container");

function ลบ แบบ declarative

ถ้ายังไม่สาแก่ใจ ลองมาทำอะไรที่มันท้าทายมากขึ้น เช่น การ sort อะลองมาวางแผนกันก่อนแบบ imperative ต้องทำยังไง ลองวาง step ออกมาก่อน

  1. เรียงของใน array ให้ถูกก่อน
  2. ลบของใน tbody ออก
  3. สั่งให้ render ใหม่
const sortButton = document.createElement("button");
sortButton.textContent = "Sort by id";
sortButton.addEventListener("click", () => {
  // เรียงของ
  users.sort((a, b) => a.id - b.id);
  
  const tbody = const tbody = document.querySelector("#table-container tbody");
  // reset ตาราง
  tbody.innerHtml = "";
  
  // วาดตารางใหม่ทีละแถว
  users.forEach((user) => {
    const tr = document.createElement("tr");
    appendCell(tr, document.createElement("td"), user.id);
    appendCell(tr, document.createElement("td"), user.name);
    appendCell(tr, document.createElement("td"), user.email);
    tbody.appendChild(tr);
  });
});

ถ้ากังวลเรื่อง performance ก็อาจจะต้องหาวิธิ optimize แต่นั่นไม่ใช่ประเด็นของบทความนี้ ประเด็นคือถ้าเราใช้ declarative เราก็จะแค่เรียงของใน array ใหม่ เดี๋ยว UI มันจะปรับตามให้เอง (บางท่านก็เลยเรียกว่า Reactive programming)

<button @click="sortById()">Sort by Id</button>
  
Vue.createApp({
  methods: {
    sortById() {
      this.users = this.users.sort((a, b) => a.id - b.id);
    },
  },
})

เรียกได้ว่าแบบ imperative เราต้องสั่งการทุกอย่างเองเกือบทั้งหมด และมีโอกาสที่เราจะเอา concern มาปนกันสูง แต่แบบ declarative ลองนึกว่าเราเป็นผู้กำกับหนัง เราคงไม่ตะโกนบอกบทให้นักแสดงทุกวินาทีใช่ไหมครับ หน้าที่เราคือแจกบทให้นักแสดง ให้เวลาเขาไปซ้อม พอถึงเวลาก็สั่งแอ็คชั่น! แล้วก็รอดูผลลัพธ์ การเขียนโปรแกรมแบบ declarative ก็คล้ายๆกัน

ใน font-end ปัจจุบัน เช่น Vue, React, SwiftUI, Jetpack หรือแม้แต่ Flutter ล้วนสนับสนุนแนวทางการเขียนแบบ declarative กันแล้ว ผมเองก็ติดขัดอยู่พอสมควรในช่วงแรกๆ ก็ต้องใช้เวลาเรียนรู้แนวคิดในการออกแบบของเค้าก่อน ใช้เวลาพอสมควรเลยครับ แต่ก็คุ้มค่านะ

แล้วเราจะออกแบบโปรแกรมแบบ declarative ได้ยังไง

ก่อนอื่นลองมองหา State หรือ data ที่ใช้สำหรับการ render UI แล้วจับมาอยู่ใน object ก้อนเดียว เรียกว่า เป็น single source of truth การเปลี่ยนแปลงทุกอย่างในหน้าจอ เกิดจากการเปลี่ยนข้อมูลที่ object ตัวเดียว ไม่ใช่แค่ข้อมูลในตาราง state อย่างเช่น loading ก็อยู่ใน state object ด้วย ยกตัวอย่างเช่นเว็บแสดงตารางที่ผมยกตัวอย่างไป ลองวาดออกมาก่อนว่า หน้าตาสุดท้ายมันเป็นแบบไหน เช่นอยากให้มันขึ้นว่า loading ก่อน พอข้อมูลมาแล้วค่อยแสดง ผมจะเพิ่ม feature เข้าไปเล็กน้อย เพื่อให้เห็นประโยชน์มากขึ้น

ที่นี้ลองค่อยๆสร้าง html ขึ้นมาที่ละ step แบบตรงๆ ทื่อๆเลย ยังไม่ต้อง template

<div id="table-container">
  <p>Loading...</p>
</div>

initial html

<div id="table-container">
  <p>Loading...</p>
  <table>
    <tbody>
          <tr>
            <td>id</td>
            <td>name</td>
            <td>email</td>
          </tr>
        </tbody>
  </table>
</div>

final html

เสร็จแล้วมองหาว่าต้องใช้ข้อมูลอะไรบ้างเพื่อให้มัน render ได้ถูกต้อง

<div id="table-container">
  // ใส่เงื่อนไขให้แสดงไว้ตรงนี้
  <p>Loading...</p>
  // ตรงนี้ก็เงื่อนไขให้แสดงตาราง
  <table>
    <tbody>
          <tr> // วนลูป แสดงข้อมูล
            <td>id</td> 
            <td>name</td>
            <td>email</td>
          </tr>
        </tbody>
  </table>
</div>

จากตัวอย่าง ก็จะเจอว่าเราต้องการสองอย่าง

isLoading: true|false //ควบคุมการแสดงข้อความ Loading...
users: [{
  id: number,
  name: string,
  email: string,
}] // Array สำหรับเก็บข้อมูลที่จะเอาไว้แสดงในตาราง

เอามาสร้างเป็น state object ได้ แล้วข้อมูลเริ่มแรกกับสุดท้าย ข้อมูลควรมีหน้าตาอย่างไร

{
  isLoading: true,
  users: [],
}

Initial state (Show loading)

{
  isLoading: false,
  users:[
    { id: 1, name: "Somsak", email: "somsak@example.com" },
    { id: 2, name: "Somchai", email: "somchai@example.com" },
    { id: 3, name: "Somsri", email: "somsri@example.com" },
  ]
}

final state (Show table)

เอาละแล้วจะ transform จาก state เริ่มต้นไปหา state สุดท้าย (แสดงตารางที่สมบูรณ์) ได้อย่างไร ในชีวิตจริงเราอาจจะไปดึงมาจาก API ก็ได้ แต่ในบทความนี้ก็ set ค่าตรงๆไปก่อน (transform จาก Array ว่างๆไปเป็น Array ที่มีข้อมูล)

updateTable() {
  users = [
    { id: 1, name: "Somsak", email: "somsak@example.com" },
    { id: 2, name: "Somchai", email: "somchai@example.com" },
    { id: 3, name: "Somsri", email: "somsri@example.com" },
  ];
  isLoading = false;
}

ทีนี้เราก็ไปดูว่า framework ที่ใช้มันต้องทำยังไง

<div id="table-container">
  <p v-if="isLoad">Loading...</p>
  <table v-else>
    <tbody>
          <tr v-for="user in users" :key="user.id">
            <td>{{ user.id }}</td>
            <td>{{ user.name }}</td>
            <td>{{ user.email }}</td>
          </tr>
        </tbody>
  </table>
</div>

หลังจากนี้ถ้ามีแก้ไขเพิ่มเติม ที่ไม่ได้เป็นการแก้ UI ก็จะ focus ที่การจัดการ state อย่างเดียวพอ เช่นเอาข้อมูลจาก API ก็แก้แค่ในฟังก์ชั่น updateTable()

หรือจะสรุปได้อีกแบบหนึ่ง

  1. How to render ไห้มันอยู่แค่ใน HTML หรือ Code ที่แตะ UI
  2. What to render แยกไปอีก layer นึง สนใจแค่ state หรือข้อมูลที่จะเอาไปใช้ render อย่างเดียว ไม่ควรแตะ UI เลย ส่วนจะไปเรียก API เอาข้อมูลอะไรต่างๆ ก็เป็นอีก layer ลงไป

ลองคิดกลับด้านเล่นๆ ถ้าจะกลับไป imperative ต้องทำอะไรบ้าง?

ถ้าใช้ framework พวกนี้แล้ว ยังมีการเขียน custom function เพื่อ manipulate DOM อยู่ อาจจะต้องลองดูดีๆอีกทีว่ามันมีทางเขียนแบบ declarative หรือเปล่า เราออกแบบหรือใช้งานอะไรผิดวัตถุประสงค์หรือไม่ เอาจริงๆมันก็ทำงานได้แหละ แต่คนมาทำต่อเค้าจะงง เปลือง cognitive load โดยไม่จำเป็น เปลือง token ด้วยถ้าใช้ AI

อีกปัญหาของ declarative ถ้าเขียนแบบ vanillaJS เพียวๆเลยเหนื่อยแน่นอน และจะมีปัญหาเรื่อง performance ด้วยถ้า ข้อมูลมีปริมาณมาก Web framework ทั้งหลายก็เลยมาช่วยตรงนี้ จุดประสงค์หลักๆส่วนใหญ่เวลาเค้าสร้าง framework คือให้เราแยก concern ออกจากกัน เรื่องที่ต้องทำซ้ำๆ บ่อยๆ เค้าจัดการให้แล้ว หน้าที่ของเราคือ วางโครงสร้างให้มันสอดคล้องกับที่เค้าออกแบบมา แล้วก็ไปลุยกับการแก้ปัญหาส่วนอื่นๆที่ควรให้ความสำคัญมากกว่า ได้อย่างเต็มที่

หมดละขอให้ Happy coding กันถ้วนหน้าครับ

ผมแปะโค้ดเต็มทั้งสองแบบไว้ตรงนี้ เผื่ออยากดูแบบเต็มๆ เทียบกัน

Imperative

<html>
  <body>
    <h1>Imperative</h1>
    <div id="table-container">
      <table>
        <thrad>
          <tr>
            <th>Id</th><th>Name</th><th>Email</th><th>Action</th>
          </tr>
        </thrad>
        <tbody></tbody>
      </table>
    </div>
  </body>
  <script>
    const headers = ["Id", "Name", "Email", "Actions"];
    let users = [
      { id: 1, name: "Somsak", email: "somsak@example.com" },
      { id: 2, name: "Somchai", email: "somchai@example.com" },
      { id: 3, name: "Somsri", email: "somsri@example.com" },
    ];

    const appendCell = (row, element, content) => {
      element.textContent = content;
      row.appendChild(element);
    };
    
    const appendActionCell = (row, userId) => {
        const actionCell = document.createElement("td");
        const deleteButton = document.createElement("button");
        deleteButton.textContent = "Delete";
        deleteButton.addEventListener("click", () => {
            users = users.filter((u) => u.id !== user.id);
            row.remove();
        });
        actionCell.appendChild(deleteButton);
        row.appendChild(actionCell);
    }

    document.addEventListener("DOMContentLoaded", () => {
      const tbody = const tbody = document.querySelector("#table-container tbody");

      users.forEach((user) => {
        const tr = document.createElement("tr");
        appendCell(tr, document.createElement("td"), user.id);
        appendCell(tr, document.createElement("td"), user.name);
        appendCell(tr, document.createElement("td"), user.email);
        tbody.appendChild(tr);
      });
    });
  </script>
</html>

Declarative

<html>
  <style>
    [v-cloak] {
      display: none;
    }
  </style>
  <body>
    <h1>Declarative</h1>
    <div id="table-container" v-cloak>
      <p v-if="isLoading">Loading...</p>
      <table v-else>
        <thead>
          <tr>
            <th v-for="header in headers" :key="header">{{ header }}</th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="user in users" :key="user.id">
            <td>{{ user.id }}</td>
            <td>{{ user.name }}</td>
            <td>{{ user.email }}</td>
            <td>
              <button @click="deleteUser(user.id)">Delete</button>
            </td>
          </tr>
        </tbody>
      </table>
    </div>
  </body>
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  <script>
    const headers = ["Id", "Name", "Email", "Action"];
    const users = [];
    Vue.createApp({
      data() {
        return {
          headers,
          users,
          isLoading: true,
        };
      },
      mounted() {
        this.updateTable();
      },
      methods: {
        updateTable() {
          setTimeout(() => {
            this.users = [
              { id: 1, name: "Somsak", email: "somsak@example.com" },
              { id: 2, name: "Somchai", email: "somchai@example.com" },
              { id: 3, name: "Somsri", email: "somsri@example.com" },
            ];
            this.isLoading = false;
          }, 1000);
        },
        deleteUser(id) {
          this.users = this.users.filter((user) => user.id !== id);
        },
      },
    }).mount("#table-container");
  </script>
</html>

Read more

เร็วแค่ไหนก็ไร้ค่า ถ้าไปผิดทาง

เร็วแค่ไหนก็ไร้ค่า ถ้าไปผิดทาง

อีกบทเรียนที่ผมได้จากหนังสือ Slack: Getting Past Burnout, Busywork, and the Myth of Total Efficiency ของ Tom DeMarco คือ ทำไมองค์กรใหญ่ ๆ ถึงยึดมั่นกับ Efficiency กันนัก Efficiency คืออะไร? Efficiency แปลว่า "ประสิทธิภาพ" ยกตัวอย่างเช่น

By Chokchai Phatharamalai
กฎของจั๊วะ

กฎของจั๊วะ

ปีนี้ที่อายุ 44 ผม Reflect ตัวเอง และพบว่าหลักการใช้ชีวิตของผมได้มาจากหนังสือ The Seven Habits of Highly Effective People เยอะมาก ใน Habit ทั้ง 7 นี้จะมีเกร็ดเล็กเกร็ดน้อยที่ผมไปศึกษามา แล้วค่อย ๆ เติมเข้าไปเพื่อทำให้ Habit นั

By Chokchai Phatharamalai
วงจรชีวิตในมุมมอง Existentialism และศิลปะแห่งการล้มเหลวในราคาถูก

วงจรชีวิตในมุมมอง Existentialism และศิลปะแห่งการล้มเหลวในราคาถูก

บ่อยครั้งที่เราใช้ชีวิตราวกับกำลังรอคอยที่จะคอมไพล์ (Compile) โปรเจกต์ยักษ์ใหญ่ที่ซับซ้อนและรวมศูนย์เพียงชิ้นเดียว เราวางแผนสำหรับทศวรรษหน้าอย่างพิถีพิถัน เรายึดโยงความสุขไว้กับจุดหมายปลายทางอันไกลโพ้นและเลือนลางของความสำเร็จสูงสุด เราเขียนโค้ดทางความคิดไว้หลายพันบรรทั

By Santi
วนเวียนแต่ไม่วนลูป: เมื่อชีวิตคือฟังก์ชัน Recursion และการเดินทางสู่พื้นที่ปลอดภัย

วนเวียนแต่ไม่วนลูป: เมื่อชีวิตคือฟังก์ชัน Recursion และการเดินทางสู่พื้นที่ปลอดภัย

ในโลกที่หมุนไปด้วยอัตราเร่งอย่างทุกวันนี้ หลายครั้งเรามักพบว่าตัวเองติดอยู่ท่ามกลางความสับสนยุ่งเหยิง ปัญหาบางอย่างในชีวิตไม่ได้มาในรูปแบบที่เรียบง่าย แต่กลับซ้อนทับกันเป็นชั้น ๆ เหมือนกล่องของขวัญใบยักษ์ที่พอเปิดเข้าไป ก็

By Santi