[functional-programming] 변경 가능한 상태없이 유용한 것을 어떻게 할 수 있습니까?

최근에 함수형 프로그래밍에 대한 많은 내용을 읽었으며 대부분을 이해할 수 있지만 머리를 감쌀 수없는 것은 상태 비 저장 코딩입니다. 변경 가능한 상태를 제거하여 프로그래밍을 단순화하는 것은 대시 보드를 제거하여 자동차를 “단순화”하는 것과 같습니다. 완제품이 더 간단 할 수 있지만 최종 사용자와 상호 작용할 수 있기를 바랍니다.

내가 생각할 수있는 거의 모든 사용자 응용 프로그램은 상태를 핵심 개념으로 포함합니다. 문서 (또는 SO 게시물)를 작성하면 새로운 입력마다 상태가 변경됩니다. 또는 비디오 게임을하는 경우 모든 캐릭터의 위치부터 끊임없이 움직이는 경향이있는 수많은 상태 변수가 있습니다. 값 변경을 추적하지 않고 어떻게 유용한 것을 할 수 있습니까?

이 문제에 대해 논의 할 내용을 찾을 때마다 실제 기술적 인 기능으로 작성되어 있으며 FP 배경이 없다고 가정합니다. 누구든지 명령형 코딩에 대해 잘 이해하고 있지만 기능 측면에서 완전한 n00b를 가진 사람에게 이것을 설명하는 방법을 알고 있습니까?

편집 : 지금까지 많은 응답이 불변 값의 장점을 설득하려고하는 것 같습니다. 나는 그 부분을 얻는다. 완벽하게 이해됩니다. 내가 이해하지 못하는 것은 가변 변수없이 변경하고 지속적으로 변경 해야하는 값을 추적하는 방법입니다.



답변

또는 비디오 게임을하는 경우 모든 캐릭터의 위치부터 끊임없이 움직이는 경향이있는 수많은 상태 변수가 있습니다. 값 변경을 추적하지 않고 어떻게 유용한 것을 할 수 있습니까?

당신이 관심이 있다면, 여기 얼랑와 게임 프로그래밍을 설명하는 일련의 기사는.

이 답변이 마음에 들지 않지만 사용할 때까지 기능적 프로그램을 얻지 못할 것입니다. 나는 코드 샘플을 게시하고 말 “여기,하지 작업을 수행 할 수 있습니다 참조 “-하지만 당신은 구문과 기본 원리를 이해하지 않으면, 당신의 눈은 유약. 당신의 관점에서 볼 때, 나는 명령형 언어와 같은 일을하는 것처럼 보이지만 의도적으로 프로그래밍을 더 어렵게 만들기 위해 모든 종류의 경계를 설정합니다. 내 관점에서, 당신은 Blub 역설을 경험하고 있습니다.

처음에는 회의적이지만 몇 년 전에 기능 프로그래밍 기차를 타고 뛰어 들었습니다. 함수형 프로그래밍의 요령은 패턴, 특정 변수 할당을 인식하고 명령 상태를 스택으로 옮길 수 있습니다. 예를 들어 for-loop는 재귀가됩니다.

// Imperative
let printTo x =
    for a in 1 .. x do
        printfn "%i" a

// Recursive
let printTo x =
    let rec loop a = if a <= x then printfn "%i" a; loop (a + 1)
    loop 1

그다지 예쁘지는 않지만 돌연변이없이 동일한 효과를 얻었습니다. 물론, 가능하면 루프를 피하고 추상화하는 것이 좋습니다.

// Preferred
let printTo x = seq { 1 .. x } |> Seq.iter (fun a -> printfn "%i" a)

Seq.iter 메소드는 콜렉션을 열거하고 각 항목에 대해 익명 함수를 호출합니다. 매우 편리합니다 🙂

숫자 인쇄가 인상적이지는 않습니다. 그러나 게임에서 동일한 접근 방식을 사용할 수 있습니다. 스택의 모든 상태를 유지하고 재귀 호출의 변경 사항으로 새 객체를 만듭니다. 이러한 방식으로 각 프레임은 게임의 상태 비 저장 스냅 샷이며, 각 프레임은 단순히 상태 비 저장 개체가 업데이트해야하는 항목을 원하는대로 변경하여 완전히 새로운 개체를 만듭니다. 이에 대한 의사 코드는 다음과 같습니다.

// imperative version
pacman = new pacman(0, 0)
while true
    if key = UP then pacman.y++
    elif key = DOWN then pacman.y--
    elif key = LEFT then pacman.x--
    elif key = UP then pacman.x++
    render(pacman)

// functional version
let rec loop pacman =
    render(pacman)
    let x, y = switch(key)
        case LEFT: pacman.x - 1, pacman.y
        case RIGHT: pacman.x + 1, pacman.y
        case UP: pacman.x, pacman.y - 1
        case DOWN: pacman.x, pacman.y + 1
    loop(new pacman(x, y))

명령형 버전과 기능 버전은 동일하지만 기능 버전은 변경 가능한 상태를 분명히 사용하지 않습니다. 함수 코드는 모든 상태를 스택에 유지합니다.이 방법의 좋은 점은 문제가 발생하면 디버깅이 쉬우 며 스택 트레이스 만 있으면된다는 것입니다.

모든 오브젝트 (또는 관련 오브젝트 모음)를 자체 스레드에서 렌더링 할 수 있기 때문에 게임에서 오브젝트의 개수에 관계없이 확장 할 수 있습니다.

내가 생각할 수있는 거의 모든 사용자 응용 프로그램은 상태를 핵심 개념으로 포함합니다.

기능적 언어에서는 객체의 상태를 변경하지 않고 원하는 변경 사항을 가진 새로운 객체를 반환합니다. 소리보다 효율적입니다. 예를 들어 데이터 구조는 변경 불가능한 데이터 구조로 표현하기가 매우 쉽습니다. 예를 들어 스택은 구현하기가 매우 쉽습니다.

using System;

namespace ConsoleApplication1
{
    static class Stack
    {
        public static Stack<T> Cons<T>(T hd, Stack<T> tl) { return new Stack<T>(hd, tl); }
        public static Stack<T> Append<T>(Stack<T> x, Stack<T> y)
        {
            return x == null ? y : Cons(x.Head, Append(x.Tail, y));
        }
        public static void Iter<T>(Stack<T> x, Action<T> f) { if (x != null) { f(x.Head); Iter(x.Tail, f); } }
    }

    class Stack<T>
    {
        public readonly T Head;
        public readonly Stack<T> Tail;
        public Stack(T hd, Stack<T> tl)
        {
            this.Head = hd;
            this.Tail = tl;
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            Stack<int> x = Stack.Cons(1, Stack.Cons(2, Stack.Cons(3, Stack.Cons(4, null))));
            Stack<int> y = Stack.Cons(5, Stack.Cons(6, Stack.Cons(7, Stack.Cons(8, null))));
            Stack<int> z = Stack.Append(x, y);
            Stack.Iter(z, a => Console.WriteLine(a));
            Console.ReadKey(true);
        }
    }
}

위의 코드는 두 개의 불변 목록을 구성하고 함께 추가하여 새 목록을 만들고 결과를 추가합니다. 응용 프로그램의 어느 곳에서도 변경 가능한 상태가 사용되지 않습니다. 약간 부피가 커 보이지만 C #이 자세한 언어이기 때문입니다. F #의 해당 프로그램은 다음과 같습니다.

type 'a stack =
    | Cons of 'a * 'a stack
    | Nil

let rec append x y =
    match x with
    | Cons(hd, tl) -> Cons(hd, append tl y)
    | Nil -> y

let rec iter f = function
    | Cons(hd, tl) -> f(hd); iter f tl
    | Nil -> ()

let x = Cons(1, Cons(2, Cons(3, Cons(4, Nil))))
let y = Cons(5, Cons(6, Cons(7, Cons(8, Nil))))
let z = append x y
iter (fun a -> printfn "%i" a) z

목록을 작성하고 조작하는 데 변경이 필요하지 않습니다. 거의 모든 데이터 구조를 기능적으로 동등한 것으로 쉽게 변환 할 수 있습니다. 나는 스택, 큐, 좌파 힙, 레드 블랙 트리, 게으른 목록의 변경 불가능한 구현을 제공 하는 페이지를 여기에 작성했습니다 . 단일 코드 스 니펫에는 변경 가능한 상태가 없습니다. 나무를 “돌연변이”시키기 위해 원하는 새 노드로 새로운 노드를 만듭니다. 이것은 트리에서 모든 노드의 복사본을 만들 필요가 없기 때문에 매우 효율적입니다. 새 노드에서 기존 노드를 재사용 할 수 있습니다. 나무.

더 중요한 예를 사용하여, 나는 완전히 상태가없는 이 SQL 파서 를 작성 했습니다 (또는 적어도 코드는 상태가 없습니다. 기본 lexing 라이브러리가 상태가 없는지 모르겠습니다).

상태 비 저장 프로그래밍은 상태 비 저장 프로그래밍과 마찬가지로 표현력이 뛰어나고 강력하므로 무 상태로 사고를 시작하도록 약간의 연습 만하면됩니다. 물론 “가능한 경우 상태 비 저장 프로그래밍, 필요한 경우 상태 저장 프로그래밍”은 가장 불완전한 기능 언어의 모토로 보입니다. 기능적 접근 방식이 깨끗하지 않거나 효율적이지 않을 때 변경 가능 변수로 넘어가는 데 아무런 해가 없습니다.


답변

짧은 대답 : 당신은 할 수 없습니다.

그렇다면 불변성에 대한 소란은 무엇입니까?

명령형 언어에 정통한 사용자라면 “전역이 나쁘다”는 것을 알고 있습니다. 왜? 그것들은 코드에서 매우 다루기 어려운 의존성을 도입하거나 도입 할 가능성이 있기 때문에. 그리고 의존성은 좋지 않습니다. 코드가 모듈화 되기를 원합니다 . 프로그램의 일부는 다른 부분에 가능한 한 영향을 미치지 않습니다. 그리고 FP는 모듈 방식의 성배 당신을 제공합니다 : 없음 부작용이 전혀은 . f (x) = y가 있습니다. x를 넣고 y를 꺼내십시오. x 나 다른 변경 사항이 없습니다. FP를 사용하면 상태에 대한 생각을 멈추고 가치 측면에서 생각을 시작할 수 있습니다. 모든 함수는 단순히 값을 받고 새로운 값을 생성합니다.

이것은 몇 가지 장점이 있습니다.

첫째, 부작용이 없다는 것은 프로그램이 단순하고 추론하기 쉽다는 것을 의미합니다. 프로그램의 새로운 부분을 도입하는 것이 기존의 작동중인 부분을 방해하고 충돌 할 염려가 없습니다.

둘째, 이것은 프로그램을 단순하게 병렬화 할 수있게합니다 (효율적인 병렬화는 또 다른 문제입니다).

셋째, 몇 가지 가능한 성능 이점이 있습니다. 기능이 있다고 가정 해보십시오.

double x = 2 * x

이제 3을 입력하고 6을 얻습니다. 매번 그러나 당신도 그렇게 할 수 있습니다. 네. 그러나 문제는 필수적으로 더 많은 것을 할 수 있다는 것 입니다. 내가 할 수있는:

int y = 2;
int double(x){ return x * y; }

그러나 나는 또한 할 수 있었다

int y = 2;
int double(x){ return x * (y++); }

명령형 컴파일러는 부작용이 있는지 여부를 알지 못하므로 최적화하기가 더 어려워집니다 (즉, 매번 두 번 2가 4 일 필요는 없습니다). 기능적인 사람은 내가 알지 못하므로 “double 2″가 표시 될 때마다 최적화 할 수 있습니다.

이제는 컴퓨터 메모리 측면에서 복잡한 유형의 값에 대해 매번 새로운 값을 생성하는 것이 엄청나게 낭비적인 것처럼 보이지만 반드시 그렇게 할 필요는 없습니다. 만약 당신이 f (x) = y를 가지고 있고 값 x와 y가 “대부분 동일하다”(예를 들어 몇 잎에서만 다른 나무들)라면, x와 y는 메모리의 일부를 공유 할 수 있습니다. .

따라서이 변경 불가능한 것이 너무 크다면 왜 내가 변경 가능한 상태없이 유용한 것을 할 수 없다고 대답 했습니까? 음, 변경이 없으면 전체 프로그램은 거대한 f (x) = y 함수가됩니다. 그리고 프로그램의 모든 부분에서도 마찬가지입니다. 단지 “순수한”의미의 함수와 함수입니다. 내가 말했듯이, 이것은 매번 f (x) = y를 의미 합니다. 따라서 readFile ( “myFile.txt”)은 매번 같은 문자열 값을 반환해야합니다. 너무 유용하지 않습니다.

따라서 모든 FP는 상태를 변경 하는 몇 가지 수단을 제공합니다 . “순수한”기능 언어 (예 : Haskell)는 모나드와 같은 다소 무서운 개념을 사용하여이 작업을 수행하지만 “불순한”언어 (예 : ML)는이를 직접 허용합니다.

물론 함수형 언어에는 일류 함수 등과 같이 프로그래밍을 더욱 효율적으로 만드는 다른 많은 장점이 있습니다.


답변

함수형 프로그래밍에 ‘상태’가 없다고 말하는 것은 약간 오해의 소지가 있으며 혼란의 원인이 될 수 있습니다. 분명히 ‘변경 가능한 상태’는 없지만 조작되는 값을 여전히 가질 수 있습니다. 그것들은 제자리에서 변경 될 수 없습니다 (예를 들어, 이전 값에서 새로운 값을 생성해야합니다).

이것은 과도하게 단순화되었지만 클래스의 모든 속성이 생성자에서만 한 번 설정되고 모든 메소드가 정적 함수 인 OO 언어가 있다고 상상해보십시오. 메소드가 계산에 필요한 모든 값을 포함하는 오브젝트를 가져 와서 결과와 함께 새 오브젝트를 리턴하도록하여 거의 모든 계산을 수행 할 수 있습니다 (같은 오브젝트의 새 인스턴스 일 수도 있음).

기존 코드를이 패러다임으로 변환하는 것은 ‘어려운’일 수 있지만 코드에 대해 완전히 다른 사고 방식이 필요하기 때문입니다. 부작용으로 대부분의 경우 무료로 병렬 처리를 할 수 있습니다.

부록 : (변경 해야하는 값을 추적하는 방법에 대한 편집 내용)
불변의 데이터 구조에 저장됩니다 …

이것은 제안 된 ‘솔루션’이 아니지만 이것이 항상 효과가 있음을 확인하는 가장 쉬운 방법은 이러한 불변 값을 ‘변수 이름’으로 키가 지정된 구조와 같은 맵 (사전 / 해시 테이블)에 저장할 수 있다는 것입니다.

분명히 실제 솔루션에서는보다 깔끔한 접근 방식을 사용하지만 다른 방법으로 아무것도 작동하지 않으면 호출 트리를 통해 이동하는 맵으로 변경 가능한 상태를 ‘시뮬레이션’할 수있는 최악의 경우를 보여줍니다.


답변

약간의 오해가 있다고 생각합니다. 순수한 기능성 프로그램에는 상태가 있습니다. 차이점은 해당 상태가 모델링되는 방식입니다. 순수한 함수형 프로그래밍에서 상태는 어떤 상태를 취하고 다음 상태를 반환하는 함수에 의해 조작됩니다. 그런 다음 상태를 순차 화하여 순차 함수 시퀀스를 통해 상태를 전달합니다.

전역 변경 가능 상태조차도 이런 식으로 모델링 할 수 있습니다. 예를 들어, Haskell에서 프로그램은 월드에서 월드로의 함수입니다. 즉, 전체 유니버스 를 전달 하면 프로그램이 새 유니버스를 반환합니다. 하지만 실제로는 실제로 프로그램에 관심이있는 우주의 일부만 통과하면됩니다. 또한 프로그램은 실제로 프로그램이 실행되는 운영 환경에 대한 지침으로 사용 되는 일련의 작업 을 반환 합니다.

명령형 프로그래밍의 관점에서 이것을 설명하고 싶었습니다. 자, 기능적 언어로 된 아주 간단한 명령형 프로그래밍을 살펴 봅시다.

이 코드를 고려하십시오.

int x = 1;
int y = x + 1;
x = x + y;
return x;

꽤 멍청한 표준 명령 코드입니다. 흥미로운 것은 없지만 설명하기에는 괜찮습니다. 여기에 관련된 주가 있다는 데 동의 할 것입니다. x 변수의 값은 시간이 지남에 따라 변경됩니다. 이제 새로운 구문을 발명하여 표기법을 약간 변경해 보겠습니다.

let x = 1 in
let y = x + 1 in
let z = x + y in z

이것이 의미하는 바를 명확하게하기 위해 괄호를 넣으십시오.

let x = 1 in (let y = x + 1 in (let z = x + y in (z)))

따라서 상태는 다음 표현식의 자유 변수를 바인딩하는 일련의 순수 표현식으로 모델링됩니다.

이 패턴은 IO를 포함한 모든 종류의 상태를 모델링 할 수 있습니다.


답변

변경 가능한 상태없이 코드를 작성하는 방법은 다음과 같습니다. 변경 가능한 상태를 변경 가능한 변수에 넣는 대신 함수의 매개 변수에 넣습니다. 루프를 작성하는 대신 재귀 함수를 작성합니다. 예를 들어이 명령 코드는 다음과 같습니다.

f_imperative(y) {
  local x;
  x := e;
  while p(x, y) do
    x := g(x, y)
  return h(x, y)
}

이 기능 코드가됩니다 (스키마와 유사한 구문).

(define (f-functional y)
  (letrec (
     (f-helper (lambda (x y)
                  (if (p x y)
                     (f-helper (g x y) y)
                     (h x y)))))
     (f-helper e y)))

또는이 Haskellish 코드

f_fun y = h x_final y
   where x_initial = e
         x_final   = loop x_initial
         loop x = if p x y then loop (g x y) else x

함수형 프로그래머가 (그렇지 않은)이 작업 을 선호 하는 이유에 관해서는 프로그램의 더 많은 부분이 무국적 일수록 더 많은 부분을 나누지 않고 조각을 모을 수있는 더 많은 방법이 있습니다 . 무국적 패러다임의 힘은 무국적 (또는 순도) 자체 가 아니라 강력하고 재사용 가능한 기능 을 작성 하고 결합 할 수있는 능력 입니다.

John Hughes의 논문 Why Functional Programming Matters 에서 많은 예제가 포함 된 유용한 자습서를 찾을 수 있습니다 .


답변

똑같은 일을하는 다른 방법 일뿐입니다.

숫자 3, 5 및 10을 추가하는 것과 같은 간단한 예를 생각해보십시오. 먼저 5를 추가하여 3의 값을 변경 한 다음 10을 해당 “3”에 추가 한 다음 ” 3 “(18). 이것은 말도 안되는 것처럼 보이지만 본질적으로 상태 기반 명령 프로그래밍이 종종 이루어지는 방식입니다. 실제로, 값이 3이지만 아직 다른 많은 “3”을 가질 수 있습니다. 이 모든 것이 이상하게 보입니다. 왜냐하면 우리는 숫자가 불변이라는 매우 엄청나게 합리적인 생각에 깊이 뿌리 박혀 있기 때문입니다.

이제 값을 변경할 수 없을 때 3, 5 및 10을 추가하는 것을 고려하십시오. 3과 5를 더하여 다른 값 8을 생성 한 다음 해당 값에 10을 더하여 또 다른 값 18을 생성합니다.

이것들은 같은 일을하는 동등한 방법입니다. 필요한 정보는 모두 두 가지 방법으로 존재하지만 다른 형식으로 존재합니다. 정보는 상태로 존재하고 상태를 변경하기위한 규칙에 있습니다. 다른 한편으로, 정보는 불변 데이터 및 기능적 정의에 존재한다.


답변

토론에 늦었지만 기능 프로그래밍에 어려움을 겪고있는 사람들에게 몇 가지 요점을 추가하고 싶었습니다.

  1. 함수형 언어는 명령형 언어와 동일한 상태 업데이트를 유지하지만 업데이트 된 상태를 후속 함수 호출로 전달하여 업데이트합니다 . 다음은 숫자 줄을 따라 이동하는 매우 간단한 예입니다. 주는 현재 위치입니다.

첫 번째 필수 방식 (의사 코드)

moveTo(dest, cur):
    while (cur != dest):
         if (cur < dest):
             cur += 1
         else:
             cur -= 1
    return cur

이제 기능적 방식 (의사 코드). 필연적 인 배경을 가진 사람들이 실제로이 코드를 읽을 수 있기를 원하기 때문에 삼항 연산자에 크게 기울고 있습니다. 따라서 삼항 연산자를 많이 사용하지 않으면 (필자는 항상 필연적 인 일을 피했습니다) 작동 방식은 다음과 같습니다.

predicate ? if-true-expression : if-false-expression

허위 표현식 대신 새로운 삼항 표현식을 넣어 삼항 표현식을 연결할 수 있습니다.

predicate1 ? if-true1-expression :
predicate2 ? if-true2-expression :
else-expression

이를 염두에두고 기능 버전이 있습니다.

moveTo(dest, cur):
    return (
        cur == dest ? return cur :
        cur < dest ? moveTo(dest, cur + 1) :
        moveTo(dest, cur - 1)
    )

이것은 간단한 예입니다. 이것이 게임 세계에서 사람들을 움직이고 있다면, 화면에 객체의 현재 위치를 그리는 것과 객체가 얼마나 빨리 움직이는 지에 따라 각 호출에서 약간의 지연을 일으키는 것과 같은 부작용을 가져와야합니다. 그러나 여전히 변경 가능한 상태가 필요하지 않습니다.

  1. 교훈은 기능 매개 변수가 다른 매개 변수로 함수를 호출하여 기능 언어가 “돌연변이”상태라는 것입니다. 분명히 이것은 실제로 변수를 변경하지는 않지만 비슷한 효과를 얻는 방법입니다. 이것은 함수형 프로그래밍을하고 싶다면 재귀 적으로 생각하는 것에 익숙해 져야한다는 것을 의미합니다.

  2. 재귀 적으로 생각하는 법을 배우는 것은 어렵지 않지만 연습과 툴킷이 모두 필요합니다. 재귀를 사용하여 계승을 계산 한 “학습 Java”책의 작은 부분은 그것을 자르지 않습니다. 재귀에서 반복적 인 프로세스를 만드는 것과 같은 기능의 툴킷이 필요합니다 (이것은 함수 재귀에 꼬리 재귀가 필수적인 이유입니다), 연속, 불변 등입니다. 액세스 수정 자, 인터페이스 등에 대한 학습없이 OO 프로그래밍을 수행하지 않을 것입니다. 기능적 프로그래밍.

Little Schemer를 추천하고 ( “읽지”말고 “읽지”말고) SICP의 모든 연습을하는 것이 좋습니다. 완료되면 시작했을 때와 다른 두뇌를 ​​갖게됩니다.