ทำความเข้าใจเกี่ยวกับฟังก์ชั่นบริสุทธิ์และผลข้างเคียงใน Haskell - putStrLn


10

เมื่อเร็ว ๆ นี้ฉันเริ่มเรียนรู้ Haskell เพราะฉันต้องการที่จะขยายความรู้ของฉันในการเขียนโปรแกรมฟังก์ชั่นและฉันต้องบอกว่าฉันรักมันจริงๆ ทรัพยากรที่ฉันใช้อยู่ในขณะนี้คือ 'Haskell Fundamentals Part 1' บน Pluralsight น่าเสียดายที่ฉันมีปัญหาในการทำความเข้าใจคำพูดหนึ่งของอาจารย์ผู้สอนเกี่ยวกับรหัสต่อไปนี้และหวังว่าพวกคุณจะเข้าใจในหัวข้อนี้

รหัสที่มาพร้อมกับ

helloWorld :: IO ()
helloWorld = putStrLn "Hello World"

main :: IO ()
main = do
    helloWorld
    helloWorld
    helloWorld

อ้าง

หากคุณมีการกระทำ IO เดียวกันหลายครั้งใน do-block มันจะถูกเรียกใช้หลายครั้ง ดังนั้นโปรแกรมนี้พิมพ์สตริง 'Hello World' สามครั้ง ตัวอย่างนี้ช่วยอธิบายว่าputStrLnไม่ใช่ฟังก์ชันที่มีผลข้างเคียง เราเรียกใช้putStrLnฟังก์ชันหนึ่งครั้งเพื่อกำหนดhelloWorldตัวแปร หากputStrLnมีผลข้างเคียงของการพิมพ์สตริงมันจะพิมพ์เพียงครั้งเดียวและhelloWorldตัวแปรซ้ำใน do-block หลักจะไม่มีผลกระทบใด ๆ

ในภาษาโปรแกรมอื่น ๆ ส่วนใหญ่โปรแกรมเช่นนี้จะพิมพ์ 'Hello World' เพียงครั้งเดียวเนื่องจากการพิมพ์จะเกิดขึ้นเมื่อมีputStrLnการเรียกใช้ฟังก์ชัน ความแตกต่างที่ลึกซึ้งนี้มักจะทำให้ผู้เริ่มต้นคิดมากขึ้นและคิดว่าคุณเข้าใจไหมว่าทำไมโปรแกรมนี้พิมพ์ 'Hello World' สามครั้งและทำไมมันถึงพิมพ์ได้เพียงครั้งเดียวถ้าputStrLnฟังก์ชั่นการพิมพ์เป็นผลข้างเคียง

สิ่งที่ฉันไม่เข้าใจ

สำหรับฉันดูเหมือนเป็นเรื่องธรรมดาที่สตริง 'Hello World' จะถูกพิมพ์สามครั้ง ฉันรับรู้helloWorldตัวแปร (หรือฟังก์ชั่น?) เป็นประเภทการโทรกลับซึ่งถูกเรียกใช้ในภายหลัง สิ่งที่ฉันไม่เข้าใจคือถ้าputStrLnมีผลข้างเคียงมันจะส่งผลให้สายที่ถูกพิมพ์เพียงครั้งเดียว หรือทำไมมันจะพิมพ์เพียงครั้งเดียวในภาษาการเขียนโปรแกรมอื่น ๆ

สมมติว่าในรหัส C # ฉันจะสมมติว่ามันมีลักษณะเช่นนี้:

C # (ซอ)

using System;

public class Program
{
    public static void HelloWorld()
    {
        Console.WriteLine("Hello World");
    }

    public static void Main()
    {
        HelloWorld();
        HelloWorld();
        HelloWorld();
    }
}

ฉันแน่ใจว่าฉันกำลังมองหาบางสิ่งที่ค่อนข้างง่ายหรือตีความคำศัพท์ของเขาผิด ความช่วยเหลือใด ๆ ที่จะได้รับการชื่นชมอย่างมาก.

แก้ไข:

ขอบคุณสำหรับคำตอบของคุณ! คำตอบของคุณช่วยให้ฉันเข้าใจแนวคิดเหล่านี้ได้ดีขึ้น ฉันคิดว่ามันยังไม่ได้คลิกเลย แต่ฉันจะกลับมาอ่านหัวข้อต่อไปในอนาคตขอบคุณ!


2
คิดว่าhelloWorldคงที่เช่นเขตข้อมูลหรือตัวแปรใน C # helloWorldมีพารามิเตอร์ไม่มีที่จะถูกนำไปใช้เป็น
Caramiriel

2
putStrLn ไม่มีผลข้างเคียง; มันแค่คืนค่าการกระทำของ IO การกระทำของIO แบบเดียวกันสำหรับการโต้แย้ง"Hello World"ไม่ว่าคุณจะโทรputStrLnไปกี่ครั้งก็ตาม
chepner

1
ถ้าเป็นเช่นนั้นhelloworldจะไม่เป็นการกระทำที่พิมพ์Hello worldออกมา ก็จะได้รับค่าส่งกลับโดยputStrLn หลังจากที่ มันพิมพ์Hello World(กล่าวคือ())
chepner

2
ฉันคิดว่าจะเข้าใจตัวอย่างนี้คุณต้องเข้าใจแล้วว่าผลข้างเคียงของ Haskell ทำงานอย่างไร มันไม่ใช่ตัวอย่างที่ดี
user253751

ใน C # ของคุณ snippet helloWorld = Console.WriteLine("Hello World");คุณไม่ทำเช่น คุณเพียงแค่มีConsole.WriteLine("Hello World");ในHelloWorldฟังก์ชั่นที่จะดำเนินการทุกครั้งที่HelloWorldมีการเรียก ตอนนี้คิดว่าเกี่ยวกับสิ่งที่ทำhelloWorld = putStrLn "Hello World" helloWorldจะได้รับมอบหมายให้ monad IO ()ซึ่งประกอบด้วย เมื่อคุณผูกมันแล้วมัน>>=ก็จะทำกิจกรรมของมัน (การพิมพ์บางอย่าง) และจะให้()ทางด้านขวาของผู้ประกอบการผูก
Redu

คำตอบ:


8

มันอาจจะง่ายกว่าที่จะเข้าใจความหมายของผู้แต่งถ้าเรานิยามhelloWorldเป็นตัวแปรเฉพาะที่:

main :: IO ()
main = do
  let helloWorld = putStrLn "Hello World!"
  helloWorld
  helloWorld
  helloWorld

ซึ่งคุณสามารถเปรียบเทียบกับ pseudocode เหมือน C # นี้:

void Main() {
  var helloWorld = {
    WriteLine("Hello World!")
  }
  helloWorld;
  helloWorld;
  helloWorld;
}

Ie ใน C # WriteLineเป็นโพรซีเดอร์ที่พิมพ์อาร์กิวเมนต์และไม่ส่งคืนอะไร ใน Haskell putStrLnเป็นฟังก์ชั่นที่รับสายและให้การกระทำที่จะพิมพ์สตริงนั้นเพื่อให้สามารถดำเนินการได้ หมายความว่าไม่มีความแตกต่างระหว่างการเขียนอย่างแน่นอน

do
  let hello = putStrLn "Hello World"
  hello
  hello

และ

do
  putStrLn "Hello World"
  putStrLn "Hello World"

ดังที่กล่าวไว้ในตัวอย่างนี้ความแตกต่างไม่ได้ลึกซึ้งอย่างยิ่งดังนั้นจึงเป็นเรื่องที่ดีถ้าคุณไม่เข้าใจสิ่งที่ผู้เขียนพยายามที่จะได้รับในส่วนนี้และเพิ่งไปต่อในตอนนี้

มันใช้งานได้ดีขึ้นเล็กน้อยถ้าคุณเปรียบเทียบกับ python

hello_world = print('hello world')
hello_world
hello_world
hello_world

ประเด็นตรงนี้คือการกระทำของ IO ใน Haskell คือค่า“ ของจริง” ที่ไม่จำเป็นต้องห่อหุ้มด้วย“ callbacks” หรือสิ่งใด ๆ เพื่อป้องกันพวกเขาจากการดำเนินการ - วิธีเดียวที่จะทำให้พวกมันทำงานได้คือ เพื่อวางไว้ในสถานที่เฉพาะ (เช่นที่ใดที่หนึ่งภายในmainหรือด้ายเกิดขึ้นmain)

นี่ไม่ได้เป็นเพียงกลอุบายเท่านั้น แต่ท้ายที่สุดก็มีเอฟเฟกต์ที่น่าสนใจเกี่ยวกับวิธีที่คุณเขียนโค้ด (ตัวอย่างเช่นมันเป็นส่วนหนึ่งของสาเหตุที่ Haskell ไม่ต้องการโครงสร้างการควบคุมทั่วไปที่คุณคุ้นเคย ด้วยภาษาที่จำเป็นและสามารถหลีกเลี่ยงการทำทุกอย่างในแง่ของฟังก์ชั่นแทน) แต่อีกครั้งฉันไม่ต้องกังวลเกี่ยวกับเรื่องนี้มากเกินไป (การเปรียบเทียบเช่นนี้ไม่ได้คลิกทันที)


4

helloWorldมันอาจจะเป็นเรื่องง่ายที่จะเห็นความแตกต่างตามที่อธิบายไว้ถ้าคุณใช้ฟังก์ชั่นที่ไม่จริงบางสิ่งบางอย่างมากกว่า คิดว่าต่อไปนี้:

add :: Int -> Int -> IO Int
add x y = do
  putStrLn ("I am adding " ++ show x ++ " and " ++ show y)
  return (x + y)

plus23 :: IO Int
plus23 = add 2 3

main :: IO ()
main = do
  _ <- plus23
  _ <- plus23
  _ <- plus23
  return ()

จะพิมพ์ออกมา "ฉันกำลังเพิ่ม 2 และ 3" 3 ครั้ง

ใน C # คุณอาจเขียนสิ่งต่อไปนี้:

using System;

public class Program
{
    public static int add(int x, int y)
    {
        Console.WriteLine("I am adding {0} and {1}", x, y);
        return x + y;
    }

    public static void Main()
    {
        int x;
        int plus23 = add(2, 3);
        x = plus23;
        x = plus23;
        x = plus23;
        return;
    }
}

ซึ่งจะพิมพ์เพียงครั้งเดียว


3

หากการประเมินผลputStrLn "Hello World"มีผลข้างเคียงข้อความจะถูกพิมพ์เพียงครั้งเดียว

เราสามารถประมาณสถานการณ์นั้นด้วยรหัสต่อไปนี้:

import System.IO.Unsafe (unsafePerformIO)
import Control.Exception (evaluate)

helloWorld :: ()
helloWorld = unsafePerformIO $ putStrLn "Hello World"

main :: IO ()
main = do
    evaluate helloWorld
    evaluate helloWorld
    evaluate helloWorld

unsafePerformIOใช้เวลาในการIOดำเนินการและ "ลืม" มันเป็นการIOกระทำโดยไม่ต้องเปิดใช้งานจากการเรียงลำดับตามปกติที่กำหนดโดยองค์ประกอบของIOการกระทำและปล่อยให้ผลกระทบเกิดขึ้น (หรือไม่) ตามความหลากหลาย

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

รหัสนี้พิมพ์ "Hello World" เพียงครั้งเดียวเท่านั้น เราปฏิบัติต่อhelloWorldคุณค่าที่บริสุทธิ์ แต่นั่นหมายความว่ามันจะถูกแชร์ระหว่างการevaluate helloWorldโทรทั้งหมด แล้วทำไมไม่ได้ล่ะ? มันมีคุณค่าอย่างแท้จริงทำไมต้องคำนวณซ้ำโดยไม่จำเป็น การevaluateกระทำแรก"ปรากฏ" เอฟเฟ็กต์ "ที่ซ่อน" และการกระทำในภายหลังจะประเมินผลลัพธ์()ที่ไม่ก่อให้เกิดผลกระทบใด ๆ เพิ่มเติม


1
เป็นที่น่าสังเกตว่าคุณไม่ควรใช้อย่างแน่นอนunsafePerformIOในขั้นตอนการเรียนรู้ Haskell นี้ มันมี "ไม่ปลอดภัย" ในชื่อด้วยเหตุผลและคุณไม่ควรใช้มันหากคุณไม่สามารถ (และทำ) พิจารณาอย่างรอบคอบถึงความหมายของการใช้งานในบริบท โค้ดที่ danidiaz ใส่ไว้ในคำตอบจะจับพฤติกรรมที่ไม่ได้เกิดจากการunsafePerformIOใช้งาน
Andrew Ray

1

มีหนึ่งรายละเอียดแจ้งให้ทราบคือคุณเรียกฟังก์ชั่นเพียงครั้งเดียวในขณะที่กำหนดputStrLn helloWorldในmainฟังก์ชั่นคุณเพียงแค่ใช้ค่าส่งคืนของputStrLn "Hello, World"สามครั้งนั้น

อาจารย์พูดว่าการputStrLnโทรนั้นไม่มีผลข้างเคียงและเป็นเรื่องจริง แต่ดูที่ประเภทของhelloWorld- มันเป็นการกระทำของ IO putStrLnเพียงสร้างมันขึ้นมาเพื่อคุณ หลังจากนั้นคุณโซ่ 3 ของพวกเขากับdoบล็อกเพื่อสร้างการกระทำ IO อื่น main- ต่อมาเมื่อคุณรันโปรแกรมการกระทำนั้นจะถูกเรียกใช้นั่นคือผลข้างเคียงที่เกิดขึ้น

กลไกที่โกหกในฐานนี้ - monads แนวคิดอันทรงพลังนี้ช่วยให้คุณใช้ผลข้างเคียงบางอย่างเช่นการพิมพ์ในภาษาที่ไม่รองรับผลข้างเคียงโดยตรง คุณเพียงเชื่อมโยงการกระทำบางอย่างและการเชื่อมโยงนั้นจะทำงานเมื่อเริ่มต้นโปรแกรมของคุณ คุณจะต้องเข้าใจแนวคิดนั้นอย่างลึกซึ้งหากคุณต้องการใช้ Haskell อย่างจริงจัง

โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.