心法 - 寫程式的邏輯

邏輯是甚麼?能吃嗎?
外在功夫可能練一下就可以有78分像,
但真正厲害的秘訣心法卻是眼睛看不到的,
也是經過一步一步累積下來的經驗,
最近感觸很深的就是,聽資深前輩講解幾個小時,可能勝讀好幾本書阿!

 

停下來幾分鐘 或許能讓小兵立大功


現在網路資源相當豐富多元,稍微有點概念的程式初學者,應該很容易就可以找到網路資源,並迅速的寫完一支Script。慢慢會發現同樣類型的問題,其實會有多套的解決方案,又或者因後續需求不同,而衍生出不同的邏輯。一開始寫程式很容易陷入習慣的思維,但對於撰寫程式而言,卻不一定是最適合的,矛盾的是看程式的又是人類。對於簡單的案子,程式碼好壞之差異性不顯著,但對於更大型的案子,勢必就會需要多人合作,也會面臨程式可讀性、維護或擴充、效能等問題。今天就來實作2個案例,並從中分享一些簡單的經驗與邏輯觀念,今天會使用C#寫示範程式,但其實接下來要講的邏輯,無論是在哪一個語言應該都是大同小異唷。究竟寫程式的過程中,會有哪些有趣的邏輯問題發生呢?就讓我們繼續看下去!

 

 

以實作學習邏輯的重要性


邏輯一、要解決的問題是甚麼?
在完成一份程式碼前,首先要面臨挑戰是-必須練習思考根本的問題是甚麼,有了明確的對象後,才能將問題拆成更小的單元去逐一完成。這裡的範例是猜拳遊戲程式,猜拳程式大概可以拆成: 玩家出的拳、電腦出的拳、判別式、結果呈現等。

 

邏輯二、簡化重複性的工作
不管是哪一套程式語言,一定會遇到重複的工作流程,土法煉鋼的逐行寫法不僅造成程式碼看起來很亂,也容易出錯。要減少重複工作流程,第一個想到的一定迴圈了,常見迴圈有for、while、foreach,雖然彼此會有部分功能上的重疊,但我會依不同情況使用:

  • 知道明確的迴圈次數時用for迴圈
  • 想要快速歷遍所有資料用foreach迴圈
  • 條件式決定迴圈次數用while迴圈,但要非常小心地再三確認,結束的條件是能成立的!

適度的使用迴圈可以減少工作量,也能讓程式碼看起來更精簡,這裡的猜拳遊戲就是使用while迴圈,讓遊戲可以反覆進行。隨著問題複雜度逐漸提高時,可能會加入if撰寫function協助判定與計算,等到更複雜的問題時,可能就會考慮物件導向設計了!提到物件導向,除了封裝、繼承、多型,當然也要提一下物件導向程式設計基本原則(SOLID)

 

邏輯三、程式碼的美學-可讀性
撰寫時若多思考一些,對於後續使用者或接手程式的人,都可以減少很多時間在「理解你的邏輯」。我將玩家出拳輸入預設為中文,電腦出拳必須是隨機的,故用內建方法random回饋一個隨機整數,並另寫function轉換成剪刀、石頭、布,這種作法對於玩家或寫程式的人,都可以很快的理解。這裡提個作法2,例如將玩家出拳輸入1、2、3也可以完成猜拳程式,但作法2會大幅降低使用者的理解性,對於後續的輸贏判斷,也會大幅降低程式碼的可讀性。再舉個例,電腦出拳可以寫成下列:

Random random = new Random();

int com = random.Next(3);         //方法一
int com = random.Next(0,3);       //方法二
int com = random.Next(1,4);       //方法三

這裡我使用方法三,因為這樣後續再寫轉換程式時,case可以直接從1開始依序表示。    

static string Com2String (int num){
    switch (num){
        case 1:
            return "剪刀";
        case 2:
            return "石頭";
        case 3:
            return "布";
        default:
            return "";
    }
}

我的方法絕對不會是最好的,寫程式沒有絕對的對與錯,想強調的是,重點在於「思考」後續應用的可能性。我自己習慣程式以0開始的概念,但上述對於閱讀程式碼而言,的確是比較直觀一點。

 

優先考量核心問題,並善用多條件分類 #範例一
猜拳遊戲最核心的的功能-勝負判斷,一般而言,我們直覺的思考模邏輯會像是:玩家會出3種拳,電腦會出3種拳,共9種可能。勝負判斷程式寫法會是,外層三個if(紅)判定玩家的拳,分別再用三個if(綠)斷電腦的拳,最後分別回傳判定結果。要在螢幕輸出勝負時,須使用9次(3*3)的Console.WriteLine()。

因為這個作法的判斷條件是依照玩家的拳型回傳判定結果。現在換一個邏輯,因為猜拳遊戲最重要的核心問題就是判斷勝負,故我們以勝負作為分類條件。這樣可先分為平手與非平手,將玩家贏的三種條件都列出,剩下的就是輸的情況,顯示勝負結果時,僅需要寫5次(1+3+1) 的Console.WriteLine()

※延伸: (1)可嘗試加入遊戲次數、勝負次數、新增離開遊戲的功能。(2)嘗試在玩家輸入時亂打,例如打「時頭」、「      布」、「123」等,並嘗試解決。

 

優先考量核心問題,並善用多條件分類 #範例二
這裡再舉第2個範例,題目為使用陣列建立5位學生、三科成績的資料,並計算個人總分、平均及單科目平均值。用Excel的方式呈現應該會更有感覺一點。

 

範例2大概可拆成: 學生陣列、科目陣列、成績陣列、輸入成績、統計結果、輸出結果。第一步就是陣列初始化啦,如果參考上面Excel的表格,我們來看看在C#中怎麼做。

//建立學生Array
string[] student = new string[5];
student[0] = "小美";
student[1] = "小王";
student[2] = "小新";
student[3] = "小吉";
student[4] = "小批";

//建立科目Array
string[] subject = new string[3];
subject[0] = "英文";
subject[1] = "數學";
subject[2] = "化學";

//建立學生成績Array
int[] A = new int[3];
int[] B = new int[3];
int[] C = new int[3];
int[] D = new int[3];
int[] E = new int[3];

 

陣列初始化時可選擇維度,如建立一維陣列、二維陣列等,同時也必須指定資料型別與陣列長度,因為陣列在儲存資料時,會找到一個固定大小且連續的記憶體空間。

int [] x = new int[3]               //一維整數陣列
int [][] y = new string[2][2]       //二維字串陣列

 

陣列給值有下列2種,但為了更快寫完範例程式,學生的成績我使用迴圈+Random.Next()隨機賦值。

//方法一 先宣告陣列後在另外賦值
int[] A = new int[3];
A[0] = 100;
A[1] = 90;
A[2] = 80;


//方法二 陣列初始化同時賦值,長度會由初始化清單中的項目數決定
int[] A = new int[] {100, 90, 80};

 

有個成績的陣列後,只要再初始化一些統計用的陣列,如下列total_A、subject_sum等,要計算總分跟平均就不是問題啦,接著再將每個人的成績、及單科目平均印出來。

//顯示學生各科成績、三科總分、平均
Console.WriteLine("小美的成績: {0}\t{1}\t{2}, 總分: {3},平均: {4}", A[0], A[1], A[2], total_A, total_A/3.0);
Console.WriteLine("小王的成績: {0}\t{1}\t{2}, 總分: {3},平均: {4}", B[0], B[1], B[2], total_B, total_B/3.0);
Console.WriteLine("小新的成績: {0}\t{1}\t{2}, 總分: {3},平均: {4}", C[0], C[1], C[2], total_C, total_C/3.0);
Console.WriteLine("小吉的成績: {0}\t{1}\t{2}, 總分: {3},平均: {4}", D[0], D[1], D[2], total_D, total_D/3.0);
Console.WriteLine("小批的成績: {0}\t{1}\t{2}, 總分: {3},平均: {4}", E[0], E[1], E[2], total_E, total_E/3.0);

//顯示各科平均
Console.WriteLine("英文的平均:" + subject_sum[0]/5.0);
Console.WriteLine("數學的平均:" + subject_sum[1]/5.0);
Console.WriteLine("化學的平均:" + subject_sum[2]/5.0);

 

若再嘗試要將程式碼再整理得更精簡,會發現我們無法使用迴圈批次打印出學生各科的成績。

//批次顯示學生各科成績、三科總分、平均 (無法使用迴圈)
Console.WriteLine("小美的成績: {0}\t{1}\t{2}, 總分: {3},平均: {4}", A[0], A[1], A[2], total_A, total_A/3.0);
Console.WriteLine("小王的成績: {0}\t{1}\t{2}, 總分: {3},平均: {4}", B[0], B[1], B[2], total_B, total_B/3.0);
Console.WriteLine("小新的成績: {0}\t{1}\t{2}, 總分: {3},平均: {4}", C[0], C[1], C[2], total_C, total_C/3.0);
Console.WriteLine("小吉的成績: {0}\t{1}\t{2}, 總分: {3},平均: {4}", D[0], D[1], D[2], total_D, total_D/3.0);
Console.WriteLine("小批的成績: {0}\t{1}\t{2}, 總分: {3},平均: {4}", E[0], E[1], E[2], total_E, total_E/3.0);

//批次顯示各科平均 (可使用迴圈)
for (int i = 0; i < 3; i++)
{
    Console.WriteLine(subject[i] + "的平均:" + subject_sum[i]/5.0);
}

 

原因就出在,若我們最終想呈現的結果,是依學生自動逐行印出成績,迴圈數會受人數的控制(5人),而我們一開始在初始化成績陣列時,是 int[] student_A = new int[3];,使用迴圈讀取一維陣列時,僅能歷遍單一陣列的內容,無法跨陣列讀取,換句話說,對於成績矩陣而言,迴圈數會受陣列長度的控制(3科),這樣導致我們無法在這裡使用迴圈讓程式更精簡,只能乖乖的一行一行寫。

若我們將成績陣列改為:

int[] Eng = new int[5];
int[] Math = new int[5];
int[] Chemical = new int[5];

 

打印學生成績的程式碼就可以精簡成:

//批次顯示學生各科成績、三科總分、平均 (成功使用迴圈)
for (int i = 0; i < 5; i++)
{
    Console.WriteLine(student[i] + "的成績為: {0} {1} {2}, 總分為: {3}, 平均為: {4}", Eng[i], Math[i], Chemical[i], total[i], total[i]/3.0);
}

※延伸: 若最後想批次印出下列結果,且僅能使用一維陣列的情況下,哪種初始化合適?

甲班的英文成績:  小美: 80, 小王: 90....
甲班的數學成績:  小美: 66, 小王: 77....
甲班的化學成績:  小美: 87, 小王: 88....

 

藉由這樣的比較,再次驗證邏輯的重要性,先花點時間搞清楚問題本質與需求,再開始寫程式,絕對可以避免掉很多砍掉重練的狀況。另外想額外補充一點,其實範例2的問題,用二維陣列應該會更快解決。陣列須要指定資料型別,同一陣列沒辦法儲存不同的資料型別,例如將姓名(string)跟成績(int)放在一起,日後我會再針對C#中集合的型別做一些Study,並單獨寫一篇網誌。