會打字就行!用 Go 語言實作好玩小專案:tree

Table of Contents

會打字就行!用 Go 語言實作好玩小專案:tree

tree 是一個很讚的工具,它可以把你的資料夾底下的檔案弄成一個樹的型狀,非常的酷。實作這個工具不難,而且也可以擴充很多很酷的功能,本篇將帶大家實作最基本的功能:遞迴印出資料夾下的檔案,我們會使用 Go 語言來實作,一起動動手吧!

什麼是樹?

樹是一種資料結構,那什麼是樹?為何叫樹?就像是我們在看星星,一顆一顆的星星連起來成了星座。我們看著許許多多的記憶體位址灑在天空,當一個位址指向另一個位址,我們就畫一條線把他們連起來,而最後這個圖形就像樹一樣。同樣的道理,這樣也能理解什麼是鏈結串列了:「排成一串的記憶體位址」,別被名字被疑惑,思考事情的本質。

type TreeNode struct {
  data any
  nodes []*TreeNode
}

樹跟我們的 tree 有什麼關係?

看看樹的形狀,再看看你的資料夾,你有發現什麼嗎?這兩個東西形狀真像呢!所以我們可以把這些目錄轉換成樹型資料結構,真是太棒了!而當我們做完第一個資料夾,我們只要遞迴的往子資料夾重複一樣的動作,就可以延伸我們的枝幹。

前往每個樹節點

透過遞迴的方式,可以輕鬆的拜訪每個樹的節點,邏輯是這樣的:拜訪這個節點,如果節點有子節點就拜訪,否則結束,非常的簡單吧!有一些方式可以讓我們選擇,這裡我們使用深度優先搜尋(Depth-First-Search,DFS),如字面上的意思,相對於廣度優先搜尋深度優先是指如果可以往下走就往下走,不能往下才往側邊走。這裡先有個概念,在接下來的實作中很有幫助。

所謂遞迴

對於不習慣使用遞迴的人來說遞迴很陌生,其實遞迴跟迴圈差不多,就是重複做一樣的事情,輸入的參數就是狀態。每一次創建樹節點就是在做一樣的事情。這次問題的差別在於查詢的路徑不一樣,所以就以路徑當作參數。以下示範遞迴與迴圈從 1 加到 10 總和的實作,還不理解的讀者可以多多思考。

func main() {
	// 以迴圈的方式
	var result int
	for i := 1; i <= 10; i++ {
		result += i
	}
	fmt.Println(result == 55)

	// 以遞迴的方式
	var recursive func(n int) int
	recursive = func(n int) int {
		if n == 1 {
			return 1
		}
		return n + recursive(n-1)
	}
	fmt.Println(recursive(10) == 55)
}

開始

程式碼:WeiTheShinobi/quack-tree: implement tree in go (github.com)

這次會分兩個部分,一是建構出樹,二是印出樹。我們先創建我們的專案,然後新增檔案,專案結構如下。

.
├── dir.go
├── go.mod
└── main.go

樹節點

這個資料結構是我們的樹節點,有自己的路徑、底下的檔案與資料夾。

檔案名:dir.go

type Dir struct {
	path    string
	files   []string
	subDirs []*Dir
}

步驟一:創建

檔案名:dir.go

func Build(path string) (*Dir, error) {
  d := &Dir{path: path}
	files, err := ioutil.ReadDir(path)
	if err != nil {
		return nil, err
	}
  
  return d, nil
}

真的很簡單,這裡先單純的創建一個Dir物件,這個Build函式會回傳(*Dir, error)。我們使用ioutil.ReadDir()替我們搜尋路徑的檔案,它會與作業系統互動並返回([]fs.FileInfo, error),我們再處理回傳值即可,現在再加上處理的程式碼。

func Build(path string) (*Dir, error) {
	d := &Dir{path: path}
	files, err := ioutil.ReadDir(path)
	if err != nil {
		return nil, err
	}
	
  // 處理回傳檔案資訊
	for _, file := range files {
    // 判斷是否是資料夾,決定要不要遞迴
		if file.IsDir() {
      // 遞迴創建樹,輸入的參數是子資料夾的路徑
			sd, err := Build(path + "/" + file.Name())
			if err != nil {
				fmt.Println(err)
				continue
			}
      // 忽略隱藏的資料夾
			if file.Name()[0] == '.' {
				continue
			}
			d.subDirs = append(d.subDirs, sd)
		} else {
      // 不是資料夾,直接加即可
			d.files = append(d.files, file.Name())
		}
	}

	return d, nil
}

這裡思路是這樣的:Build出一個節點,Build出它的子節點,重複做下去。

步驟二:印出

印出的實作與創建差不多,同樣以深度優先的方式走過每個節點,走過即印出,並不難,我們可以先從簡單的開始,再慢慢完善功能。

檔案名:main.go

func main() {
	d, _ := Build(".")
	fmt.Println(".")
	d.Print()
}

檔案名:dir.go

func (d *Dir) Print() {
	for _, fileName := range d.files {
		fmt.Println(fileName)
	}

	for _, dir := range d.subDirs {
		fmt.Println(dir.path)
    // 遞迴
		dir.Print()
	}
}

輸出:

go run .

.
dir.go
go.mod
main.go

這一段簡單的程式碼就可以印出資料夾了,但是這還不夠,因為這沒有印出資料夾的結構,而只是印出檔名而已,我們想要一目了然,看起來像個,越深的節點需要在前面印出越多空白或是符號,如下所示。

    │   │   ├── foo
    │   │   │   └── foo.go

繼續編寫我們的程式碼,讓它能夠輸出樹的結構,而這會用到一些符號,照著以下規則輸出:

  • 用於最後一個檔案。
  • 非最後一個檔案。
  • 用於連接。
  • 父資料夾如果有其他檔案還沒輸出,前綴就要加上,反之是空格。

我們只要依照這四個規則實作即可,至於前綴的話,它代表該節點的每個父節點是不是最後一個資料夾,我們可以選擇修改函式的簽名,把前綴當作參數傳入。雖然看起來很複雜,但就是在原有的結構上加上一些條件,並不難,改寫好得程式碼如下:

檔案名:dir.go

func (d *Dir) Print(prefix string) {
	for i, fileName := range d.files {
    // 如果是最後一個檔案的情況
		if i == len(d.files)-1 && len(d.subDirs) == 0 {
			fmt.Printf("%s└── %s\n", prefix, fileName)
		} else {
			fmt.Printf("%s├── %s\n", prefix, fileName)
		}
	}

	for i, dir := range d.subDirs {
		// 將路徑以 `/` 分割,取最後一個
		splitPath := strings.Split(dir.path, "/")
		lastFileName := splitPath[len(splitPath)-1]

		if i == len(d.subDirs)-1 {
      // 如果是最後一個檔案的情況
			fmt.Printf("%s└── %s\n", prefix, lastFileName)
			dir.Print(prefix + "    ")
		} else {
			fmt.Printf("%s├── %s\n", prefix, lastFileName)
			dir.Print(prefix + "│   ")
		}
	}
}
go run .
.
├── dir.go
├── go.mod
└── main.go

這樣我們就能輸出美麗的資料夾樹了,真是太棒了!

編譯執行檔

這裡示範用go build來編譯出執行檔,-o可以寫檔案名,也可以直接把路徑寫入,只要把執行檔放在有環境變數的地方即可使用囉!以下是我的電腦為例。

go build -o ~/go/bin/quack-tree
quack-tree

還可以增加的功能

有很多有趣的功能可以考慮:

  • 例如選擇檔案路徑

  • 完整路徑名

  • 效能優化

  • 把結果輸出成一個檔案

  • 其他酷東西

當然在你可能還需要實作一個命令列參數分析來解析輸入,讓程式知道需要做什麼,然後你就可以用-a--help等等之類的神奇魔法來控制程式,就像在用其他功能一樣,像是ls -a

結語

感謝觀看,自己動手做會很有成就感的,實作是很重要的,思考樹和遞迴,希望你喜歡這次的內容,最後再提供一個簡潔的寫法。

WeiTheShinobi

func printTree(path string, prefix string) {
	files, err := ioutil.ReadDir(path)
	if err != nil {
		fmt.Println(err)
		return
	}

	for i, file := range files {
		if file.IsDir() {
			if i == len(files)-1 {
				fmt.Printf("%s└── %s\n", prefix, file.Name())
				printTree(filepath.Join(path, file.Name()), prefix+"    ")
			} else {
				fmt.Printf("%s├── %s\n", prefix, file.Name())
				printTree(filepath.Join(path, file.Name()), prefix+"│   ")
			}
		} else {
			if i == len(files)-1 {
				fmt.Printf("%s└── %s\n", prefix, file.Name())
			} else {
				fmt.Printf("%s├── %s\n", prefix, file.Name())
			}
		}
	}
}