時間越長,我就越成為函數式程式設計的愛好者。即使當我在 OOP 程式碼庫中工作時,我也會嘗試應用旨在簡化程式碼並更輕鬆地預測結果的小概念。身為 Ruby 專家,我也喜歡使用功能程式碼編寫單元測試是多麼簡單。

本文的目的是分享我對函數式程式設計概念的看法,並提出一種可以在已編寫的 OOP 程式碼中使用函數式概念的方法。希望我們能夠停止爭論哪種範式更好,並開始編寫好的想法,每次都能產生更好的程式碼!

目錄

一開始,有函數式編程

功能性

函數式程式設計範式於 1958 年隨著第一個 Lisp 語言的出現而出現(美好的時光)。它的根源可以追溯到 Alonzo Church 的 lambda 演算。函數式程式設計的核心原則圍繞著最小化對程式碼庫中狀態的依賴。

與允許狀態但強調封裝的物件導向程式設計 (OOP) 不同,函數式程式設計師努力優先編寫無狀態元件。這種方法鼓勵建立獨立於外部狀態變數的程式碼。

此外,即使引入了狀態,在編寫狀態時也必須考慮不變性、函數的純度,甚至避免副作用。隨著本文的深入,所有這些概念都將進一步介紹。

進入OOP,這個範式是什麼?

打開

OOP,或更廣為人知的名稱為“物件導向程式設計”,是一種可以追溯到 1967 年的範式。其最偉大的代表是 Simula、Smalltalk 和 Java。背後的思想過程是透過強制封裝實踐來將這些狀態以及修改它們的任何行為分組到公共「實體」或「物件」下,從而減少「全局」狀態的數量。

事實上,「物件導向程式設計」這個名字多年來一直被廣泛討論。 OOP 的建立者之一 Alan Key 實際上希望更多地關注該範例的訊息傳遞方面。這意味著我們應該強調封裝並允許物件之間進行狀態和行為的通訊。也許在不同的宇宙中,我們可以擁有「面向訊息的程式」。然而,OOP 這個名字已經經久不衰,而我們就在這裡!

我不知道你怎麼想,但是這個考慮範式的另一個可能名稱的簡單過程讓我的思維變得瘋狂,重新思考了一些概念,並實際上簡化了我建置軟體的方式。

什麼是類別以及我們如何以不同的方式思考它

什麼是類

我想每個人都聽過那個經典的講座,其中我們學到了“動物”類,其中包括“狗”類,對嗎?你可能聽過同樣的話(至少我聽過)。

類別是現實世界中實體的藍圖,描述其特徵和操作。

雖然不正確,但我想建議稍微改變一下單字的用法,以幫助澄清封裝,以便更好地理解,就像它對我所做的那樣。讓我們考慮以下新引文:

類別是一種封裝狀態和在該狀態上操作的行為的方法。

這個簡單的字的改變確實讓我的思想改變了。我不再嘗試將類別視為現實世界的實體,而是開始簡單地將其視為將具有相似上下文的狀態分組在一起並公開對這些狀態進行操作的函數的另一種方式。我希望這個小小的改變也能幫助你回顧自己的概念!

以這種方式抽象化這個概念的重要性是,在從具有不同結構(例如模組)的語言中讀取程式碼時變得熟練。我們可以觀察到這段 OOP 程式碼是用 typescript 寫的:

class Github {
  private _url: string;
  privale _repo: string;
  private _username: string;

  constructor(url: string, repo: string, username: string) {
    this._repo = repo
    this._username = username
    this._url = url
  }

  public createRepo(name: string): void {
    // TODO: do stuff here using the provided state in _url, _repo and _username
  }
}

與此 Elixir 程式碼完全等效,即使 Elixir 程式碼使用“模組”而不是“類別”:

defmodule Github do
  @url ""
  @repo ""
  @username ""

  defstruct url: @url, repo: @repo, username: @username

  def new(url, repo, username) do
    %Github{url: url, repo: repo, username: username}
  end

  def create_repo(%Github{repo: repo, username: username}, name) do
    # TODO: do stuff here using the provided state in url, repo, and username
  end
end

接下來我們將研究一些功能概念並進一步討論這些範例的合併,讓我們開始吧!

變異還是不變異:什麼是不變性

不變性

現在我們正在達到第一個真正的功能概念,而且是一個非常重要的概念,我可以補充一下(一切都在這裡計劃)!為了正確理解不變性,讓我們回顧一下在程式設計中處理值的方式:

通常,我們將值綁定到變數,以便稍後可以對它們進行操作,對嗎?就像簡單的事情

# Bounding values to variables
name = 'Cherry'
age = 23

# Operating on it and bounding to another variable
year_born = Time.now.year - age

# Printing it
puts "#{name} has #{age} years old and was born at #{year_born}"

對於這些變數,透過更新其值來更改原始變數是很常見的,但這裡缺少的是:修改變數是一種破壞性操作。

但為什麼?好吧,讓我們想像一下多個操作(函數或程式碼區塊)在不同時刻和頻率修改相同變數。在這種情況下,我們會產生很多問題,例如:

  • 1. 無法對操作重新排序或根本無法更改它們:當我們有如此多的依賴程式碼時,甚至很難對程式碼進行重新排序或更改,因為所有內容都綁定到特定的更改順序。

  • 2. 理解程式碼在做什麼的心理負擔:雖然這是個人觀點,但我認為這是一個廣受認可的觀點。高度可變的程式碼很容易變得混亂且難以理解資料流,需要除錯器等工具來逐步完成轉換。

  • 3. 測試時的困難:模擬函數轉換的特定狀態確實很困難,這將逐漸擴展您的單元測試,直到它們不再是單元。

不變性可以定義為避免更改(或變異)程式內任何變數的做法。儘管根據語言的不同,我們可能需要做出讓步並改變一些控制變數,但這裡要學到的總體教訓是:

我們應該不惜一切代價避免改變沒有定義範圍的變數。

透過這句話,我的意思是可以在函數內建立作用域變數並在那裡對其進行變異。然而,一旦您將這個可變變數傳遞給另一個函數,您就會增加改變相同變數的目標數量,並且您將慢慢失去控制。這正是我們想要避免的情況!

床底下的怪物:有什麼副作用

副作用

每當有人提出這個話題來討論時,這個話題就會引起很大的熱度。我可能不會涵蓋這個主題的每一個細微差別,但我一定會向您解釋它們是什麼以及我如何在我自己的軟體中管理副作用,好嗎?

那麼,副作用就是透過呼叫協定(HTTP、WebSocket、GraphQL 等)甚至操作 stdin/stdout 與外部資源(或「外部世界」)互動的每一次計算。是的,我知道,即使是我們無害的print也會在這裡受到指責。 😔

但與變異性不同的是,我們不應該盡可能避免使用它,而應該將其隔離在單獨處理副作用的特定函數中。這樣,我們將程式碼分為「不執行任何副作用的函數」和「執行副作用的函數」。但為什麼要擔心這種分離呢?

每次我們觸發對「外部世界」的任何操作時,我們都會失去對這個特定計算部分可能發生的情況的控制(例如在執行 HTTP 呼叫時,伺服器可能會關閉或可能根本不存在)。其他問題包括測試困難和程式碼可預測性降低。

由於我們無法編寫沒有副作用的任何現實世界軟體,因此一般建議是將其聚集成小函數,透過對錯誤的適當抽象來單獨處理它。這樣,就可以只測試我們 100% 控制的函數,並模擬所有執行副作用的函數。

例如,請考慮以下執行 HTTP 請求的函數以及轉換從該請求傳回的資料的小函數。

require 'faraday'

module MyServiceModule
  # This function perform side effects
  def perform_http_request
    conn = Faraday.new(url: "fakeapi.com")

    begin
      response = conn.get
      {ok: true, data: response.body}
    rescue  => e
      {ok: false, error: e}
    end
  end

  # These functions doens't perform any side effects
  def upcase_name(name)
    return '' unless name.is_a?(String)

    name.upcase
  end

  def retrieve_born_year(age)
    return 0 unless age.is_a?(Integer)

    Time.now.year - age
  end
end

看到我怎麼說「錯誤周圍的抽象」了嗎?這正是上面的程式碼範例中實現的,而不是讓異常在我們針對雜湊抽象的程式碼中冒泡。

在使用「副作用」和「無副作用」之間的明確定義來定義這些函數之後,很容易預測程式碼中會發生什麼,也更容易測試,如下所示:

require 'minitest/autorun'

class TestingStuff < Minitest::Test
  def test_upcase_name
    assert_equal MyServiceModule.upcase_name "cherry", "CHERRY"
    assert_equal MyServiceModule.upcase_name "kalane", "KALANE"
    assert_equal MyServiceModule.upcase_name "Thales", "THALES"
  end

  def test_retrieve_born_year
    Time.stub :now, Time.new(2024, 3, 5) do
      assert_equal MyServiceModule.retrieve_born_year 23, 2001
      assert_equal MyServiceModule.retrieve_born_year 20, 2004
      assert_equal MyServiceModule.retrieve_born_year 14, 2010
    end
  end
end

這個策略真的很棒,因為您甚至不需要在測試時擔心副作用部分,只需為實際執行某些操作的程式碼轉換部分編寫斷言,您最終會得到更好的測試,真正驗證重要的內容您的程式碼庫的一部分!整齊吧?

隔離一切:什麼是純函數

純函數

現在是時候總結到目前為止所獲得的所有知識了。在前面的範例中,我們觀察到程式碼分為「副作用」和「無副作用」。我們還看到了這些功能如何更容易測試,並且我們的主要轉換業務邏輯應該保持隔離。您想知道這些函數叫什麼嗎?它們是純函數

讓我們檢查純函數的正確形式定義並逐步探索這個概念。

純函數是尊重不變性、不執行任何副作用並且在給定相同參數的情況下傳回相同輸出的函數。

基本上,純函數遵循我們之前提到的所有原則,而且它們總是為相同的參數產生相同的返回。讓我們來看看之前的函數。

def upcase_name(name)
  return '' unless name.is_a?(String)

  name.upcase
end

upcase_name('cherry') # => Will be *always* CHERRY

使用純函數,我們可以輕鬆定義多個斷言,因為我們不受任何需要大量模擬的上下文的約束。我們只需用靜態值傳遞所需的參數,就這樣!

由於純函數非常小且可組合,因此它們的數量增加得非常快。為了解決這個問題,像 Elixir 這樣的函數式語言提供了像管道這樣的組合運算符,這使得按順序執行多個純函數變得非常容易。

"cherry  "
|> trim
|> upcase # => "CHERRY"

管道運算子源自 Bash 等函數。您可以在這裡閱讀更多相關資訊:[ https://dev.to/cherryramatis/linux-filters-how-to-streamline-text-like-a-boss-2dp4#what-is-a-pipeline ]

使用函數式模式而不需要完整的 haskell

都是功能啊

我一直害怕學習函數範式,因為社群透過使用現成的句子和大概念讓每個試圖學習一些小技巧的人變得非常複雜。在掌握了許多函數式語言並嘗試盡可能多地學習之後,我的目標是簡化這些概念,最重要的是,提倡在 OOP 程式碼中使用函數式概念。

應用純函數(或純方法,如果您願意)、不變性和副作用分離可以使您的 OOP 程式碼看起來更乾淨和解耦。你不需要知道什麼是 monad 或如何在 Haskell 中手動編寫編譯器;您可以使用簡單而有效的函數概念來堅持使用 Ruby on Rails!

我希望透過這篇小文章(以及本系列中的文章),無論您選擇哪種語言和框架,您都可以透過可組合性和簡單性來改進您的程式碼庫。

結論

這篇文章是我嘗試使函數範式的知識民主化(在我的能力和專業知識範圍內)。需要強調的是,我不是函數式專家,本文針對的是了解 OOP 並對函數式程式設計感興趣的初學者。我希望它有用,並且我願意提供任何需要的幫助。願原力與你同在🍒


原文出處:https://dev.to/cherryramatis/ending-the-war-or-continuing-it-lets-bring-functional-programming-to-oop-codebases-3mhd


共有 0 則留言