Wie man nützliche Java-Programme schreibt, ohne veränderbare Variablen zu verwenden


12

Ich habe einen Artikel über funktionale Programmierung gelesen, in dem der Autor angibt

(take 25 (squares-of (integers)))

Beachten Sie, dass es keine Variablen hat. In der Tat hat es nicht mehr als drei Funktionen und eine Konstante. Versuchen Sie, die Quadrate von Ganzzahlen in Java zu schreiben, ohne eine Variable zu verwenden. Oh, es gibt wahrscheinlich eine Möglichkeit, dies zu tun, aber es ist sicherlich nicht natürlich und es würde nicht so gut lesen wie mein Programm oben.

Ist es möglich, dies in Java zu erreichen? Angenommen, Sie müssen die Quadrate der ersten 15 Ganzzahlen drucken. Können Sie eine for- oder while-Schleife schreiben, ohne Variablen zu verwenden?

Mod hinweis

Diese Frage ist kein Code-Golf-Wettbewerb. Wir suchen nach Antworten, die die Konzepte erklären (im Idealfall ohne Wiederholung früherer Antworten), und nicht nur nach einem weiteren Teil des Codes.


19
Ihr Funktionsbeispiel macht Gebrauch Variablen auf der Innenseite, aber die Sprache tut all das hinter den Kulissen. Sie haben die unangenehmen Teile effektiv an jemanden delegiert, von dem Sie glauben, dass er sie richtig gemacht hat.
Blrfl

12
@Blrfl: Das Argument "hinter den Kulissen" beendet alle sprachbasierten Debatten, da letztendlich jeder Code in x86-Maschinencode übersetzt wird. x86-Code ist nicht objektorientiert, nicht prozedural, nicht funktionsfähig, nichts, aber diese Kategorien sind wertvolle Tags für Programmiersprachen. Sehen Sie sich die Sprache an, nicht die Implementierung.
Thiton

10
@ Thiton nicht einverstanden. Was Blrfl sagt, ist, dass diese Funktionen wahrscheinlich Variablen verwenden, die in derselben Programmiersprache geschrieben sind . Hier muss man nicht tief gehen. Das Snippet verwendet lediglich Bibliotheksfunktionen. Sie können problemlos denselben Code in Java schreiben, siehe: squaresOf(integers()).take(25)(Das Schreiben dieser Funktionen ist eine Übung für den Leser; die Schwierigkeit liegt in der unendlichen Menge integers(), aber das ist ein Problem für Java, da es eifrig evaluiert wird und nichts damit zu tun hat Variablen)
Andres F.

6
Dieses Zitat ist verwirrend und irreführend. Es gibt keine Magie, nur syntaktischen Zucker .
Yannis

2
@thiton Ich schlage vor, Sie lernen mehr über FP-Sprachen, aber das Code-Snippet unterstützt (oder lehnt) die Behauptung nicht ab, dass "Variablen" nicht benötigt werden (womit Sie vermutlich "veränderbare Variablen" meinen, weil die andere Art ist üblich in FP). Das Snippet zeigt nur Bibliotheksfunktionen, die auch in Java implementiert sein könnten, abgesehen von faulen / eifrigen Problemen, die hier häufig auftreten.
Andres F.

Antworten:


31

Ist es möglich, ein solches Beispiel in Java zu implementieren, ohne destruktive Updates zu verwenden? Ja. Wie @Thiton und der Artikel selbst bereits erwähnten, wird es jedoch hässlich sein (je nach Geschmack). Eine Möglichkeit ist die Verwendung von Rekursion. Hier ist ein Haskell- Beispiel, das etwas Ähnliches tut:

unfoldr      :: (b -> Maybe (a, b)) -> b -> [a]
unfoldr f b  =
  case f b of
   Just (a,new_b) -> a : unfoldr f new_b
   Nothing        -> []  

Anm. 1) das Fehlen von Mutationen, 2) die Verwendung von Rekursionen und 3) das Fehlen von Schleifen. Der letzte Punkt ist sehr wichtig - funktionale Sprachen benötigen keine in die Sprache eingebauten Schleifenkonstrukte, da die Rekursion für die meisten (alle?) Fälle verwendet werden kann, in denen Schleifen in Java verwendet werden. Hier ist eine bekannte Reihe von Artikeln, die zeigen, wie unglaublich ausdrucksstark Funktionsaufrufe sein können.


Ich fand den Artikel unbefriedigend und möchte ein paar zusätzliche Punkte ansprechen:

Dieser Artikel ist eine sehr schlechte und verwirrende Erklärung der funktionalen Programmierung und ihrer Vorteile. Für das Erlernen der funktionalen Programmierung empfehle ich dringend andere Quellen .

Der verwirrendste Teil des Artikels ist, dass nicht erwähnt wird, dass es in Java (und den meisten anderen gängigen Sprachen) zwei Verwendungszwecke für Zuweisungsanweisungen gibt:

  1. Einen Wert an einen Namen binden: final int MAX_SIZE = 100;

  2. destruktives Update: int a = 3; a += 1; a++;

Die funktionale Programmierung vermeidet die zweite, umfasst aber die erste (Beispiele: let-Ausdrücke, Funktionsparameter, I definetionen der obersten Ebene ) . Dies ist ein sehr wichtiger Punkt zu erfassen, da sonst nur der Artikel albern scheint , und vielleicht lassen Sie sich fragen, was ist take, squares-ofund integerswenn keine Variablen?

Darüber hinaus ist das Beispiel bedeutungslos. Es zeigt nicht die Implementierungen take, squares-ofoder integers. Soweit wir wissen, werden sie mit veränderlichen Variablen implementiert. Wie @Martin sagte, können Sie dieses Beispiel einfach in Java schreiben.

Ich würde wieder einmal empfehlen, diesen Artikel zu meiden und andere mögen ihn, wenn Sie wirklich etwas über funktionale Programmierung lernen möchten. Es scheint eher mit dem Ziel geschrieben zu sein, zu schockieren und zu beleidigen, als Konzepte und Grundlagen zu vermitteln. Schauen Sie sich stattdessen eine meiner Lieblingszeitungen von John Hughes an. Hughes versucht, einige der gleichen Probleme anzugehen, die in dem Artikel behandelt wurden (obwohl Hughes nicht über Parallelität / Parallelisierung spricht). Hier ist ein Teaser:

Dieses Papier ist ein Versuch, der größeren Gemeinschaft von (nicht funktionsfähigen) Programmierern die Bedeutung der funktionalen Programmierung zu demonstrieren und den funktionalen Programmierern dabei zu helfen, ihre Vorteile voll auszuschöpfen, indem klargestellt wird, was diese Vorteile sind.

[...]

Wir werden im Rest dieses Papiers argumentieren, dass funktionale Sprachen zwei neue, sehr wichtige Arten von Leim liefern. Wir werden einige Beispiele für Programme nennen, die auf neue Weise modularisiert und dadurch vereinfacht werden können. Dies ist der Schlüssel zur Leistungsfähigkeit der funktionalen Programmierung - sie ermöglicht eine verbesserte Modularisierung. Es ist auch das Ziel, nach dem sich funktionierende Programmierer bemühen müssen - kleinere, einfachere und allgemeinere Module, die mit den neuen Klebstoffen, die wir beschreiben werden, zusammengeklebt werden.


10
+1 für "Ich würde empfehlen, diesen Artikel zu meiden, und andere mögen ihn, wenn Sie wirklich etwas über funktionale Programmierung lernen möchten. Er scheint eher mit dem Ziel geschrieben zu sein, zu schockieren und zu beleidigen, als Konzepte und Grundlagen zu vermitteln."

3
Die Hälfte des Grundes, warum Leute FP nicht machen, ist, weil sie in uni nichts davon hören / lernen, und die andere Hälfte ist, weil sie darin Artikel finden, die sie beide uninformiert lassen und denken, es ist alles für einige Phantasievolle zu spielen, anstatt ein durchdachter Ansatz mit Vorteilen zu sein. +1 für bessere Informationsquellen
Jimmy Hoffa

3
Stellen Sie Ihre Antwort auf die Frage ganz oben, wenn Sie möchten, ist sie direkter und möglicherweise bleibt diese Frage dann offen (mit einer direkten frageorientierten Antwort)
Jimmy Hoffa

2
Entschuldigung, aber ich verstehe nicht, warum Sie diesen Hashcode gewählt haben. Ich habe LYAH gelesen und dein Beispiel ist schwer zu verstehen. Ich sehe auch keinen Zusammenhang mit der ursprünglichen Frage. Warum haben Sie nicht einfach take 25 (map (^2) [1..])als Beispiel genommen?
Daniel Kaplan

2
@ tieTYT gute Frage - danke für den Hinweis. Der Grund, warum ich dieses Beispiel verwendet habe, ist, dass es zeigt, wie eine Liste von Zahlen unter Verwendung von Rekursion und Vermeidung von veränderlichen Variablen erzeugt wird. Ich wollte, dass das OP diesen Code sieht und darüber nachdenkt, wie man etwas Ähnliches in Java macht. Was ist, um Ihr Code-Snippet zu adressieren [1..]? Das ist eine coole Funktion, die in Haskell integriert ist, zeigt aber nicht die Konzepte, die hinter der Erstellung einer solchen Liste stehen. Ich bin sicher, dass Instanzen der EnumKlasse (die diese Syntax erfordert) ebenfalls hilfreich sind, aber zu faul waren, um sie zu finden. So ist unfoldr. :)

27

Das würdest du nicht. Variablen sind der Kern der imperativen Programmierung, und wenn Sie versuchen, imperativ zu programmieren, ohne Variablen zu verwenden, bereiten Sie nur allen Ärgernissen. In unterschiedlichen Programmierparadigmen sind die Stile unterschiedlich, und unterschiedliche Konzepte bilden Ihre Grundlage. Eine Variable in Java ist, wenn sie mit einem kleinen Bereich gut verwendet wird, nicht böse. Ein Java-Programm ohne Variablen anzufordern ist wie ein Haskell-Programm ohne Funktionen anzufordern, also fragen Sie nicht danach und lassen Sie sich nicht dazu verleiten, die imperative Programmierung als minderwertig anzusehen, weil sie Variablen verwendet.

Der Java-Weg wäre also:

for (int i = 1; i <= 25; ++i) {
    System.out.println(i*i);
}

und lassen Sie sich nicht täuschen, es aufgrund des Hasses auf Variablen auf komplexere Weise zu schreiben.


5
"Hass auf Variablen"? Ooookay ... Was hast du über funktionale Programmierung gelesen? Welche Sprachen haben Sie ausprobiert? Welche Tutorials?
Andres F.

8
@AndresF .: Mehr als zwei Jahre Kursarbeit in Haskell. Ich sage nicht, dass FP schlecht ist. In vielen FP-vs-IP-Diskussionen (wie dem verlinkten Artikel) besteht jedoch die Tendenz, die Verwendung neu zuweisbarer benannter Entitäten (AKA-Variablen) zu verurteilen und ohne triftigen Grund oder Daten zu verurteilen. Unangemessene Verurteilung ist in meinem Buch Hass. Und Hass sorgt für wirklich schlechten Code.
Thiton

10
"Variablenhass" ist eine kausale Vereinfachung de.wikipedia.org/wiki/Fallacy_of_the_single_cause Die zustandslose Programmierung bietet viele Vorteile, die sogar in Java möglich wären, obwohl ich Ihrer Antwort zustimme, dass die Kosten in Java zu hoch wären das Programm und nicht idiomatisch zu sein. Ich würde die Idee, dass zustandslose Programmierung gut und zustandsbehaftet ist, immer noch nicht per Hand wegwinken, da es sich eher um eine emotionale Reaktion als um eine begründete, gut durchdachte Haltung handelt, zu der Menschen aufgrund von Erfahrungen gekommen sind.
Jimmy Hoffa

2
In Anlehnung an @JimmyHoffa verweise ich Sie auf John Carmack zum Thema funktionaler Programmierstil in imperativen Sprachen (C ++ in seinem Fall) ( altdevblogaday.com/2012/04/26/functional-programming-in-c ).
Steven Evers

5
Unangemessene Verurteilung ist kein Hass, und es ist nicht unvernünftig, einen veränderlichen Zustand zu vermeiden.
Michael Shaw

21

Das einfachste, was ich mit Rekursion machen kann, ist eine Funktion mit einem Parameter. Es ist nicht sehr Java-artig, aber es funktioniert:

public class squares
{
    public static void main(String[] args)
    {
        squares(15);
    }

    private static void squares(int x)
    {
        if (x>0)
        {
            System.out.println(x*x);
            squares(x-1);
        }
    }
}

3
+1 für den Versuch, die Frage tatsächlich mit einem Java-Beispiel zu beantworten.
KChaloux

Ich würde dies für eine Code- Präsentation im Golfstil (siehe Mod-Hinweis ) ablehnen , kann mich aber nicht dazu zwingen, den Pfeil nach unten zu drücken, da dieser Code perfekt mit den Aussagen in meiner Lieblingsantwort übereinstimmt : "1) das Fehlen von Mutationen, 2) die Verwendung von Rekursion und 3) das Fehlen von Schleifen "
gnat

3
@gnat: Diese Antwort wurde vor dem Mod-Hinweis gepostet. Ich wollte keinen großen Stil, ich wollte einfach sein und die ursprüngliche Frage des OP befriedigen. zu zeigen , dass es ist möglich , solche Dinge in Java zu tun.
FrustratedWithFormsDesigner

@FrustratedWithFormsDesigner sicher; Das würde mich nicht davon abhalten, Videos zu drehen (da Sie in der Lage sein sollten, diese zu bearbeiten ) - es ist das auffallend perfekte Match , das die Magie auslöste . Gut gemacht, wirklich gut gemacht, ziemlich lehrreich - danke
Mücke

16

In Ihrem Funktionsbeispiel sehen wir nicht, wie die Funktionen squares-ofund takeimplementiert sind. Ich bin kein Java-Experte, aber ich bin mir ziemlich sicher, dass wir diese Funktionen schreiben könnten, um eine Aussage wie diese zu ermöglichen ...

squares_of(integers).take(25);

das ist nicht so ganz anders.


6
Nitpick: squares-ofist kein gültiger Name in Java ( squares_ofist aber). Aber ansonsten ein guter Punkt, der zeigt, dass das Beispiel des Artikels schlecht ist.

Ich vermute, dass der Artikel integerträge Ganzzahlen generiert und die takeFunktion 25 der squared-ofZahlen aus wählt integer. Kurz gesagt, Sie sollten eine integerFunktion haben, mit der Sie Ganzzahlen bis unendlich träge erzeugen können.
OnesimusUnbound

Es ist ein bisschen verrückt, so etwas wie (integer)eine Funktion aufzurufen - eine Funktion ist immer noch etwas, das ein Argument einem Wert zuordnet. Es stellt sich heraus, dass dies (integer)keine Funktion, sondern nur ein Wert ist. Man könnte sogar so weit gehen zu sagen, dass integeres sich um eine Variable handelt, die an einen unendlichen Strang von Zahlen gebunden ist.
Ingo

6

In Java können Sie dies (insbesondere den unendlichen Listenteil) mit Iteratoren tun. Im folgenden Codebeispiel kann die an den TakeKonstruktor übergebene Zahl beliebig hoch sein.

class Example {
    public static void main(String[] a) {
        Numbers test = new Take(25, new SquaresOf(new Integers()));
        while (test.hasNext())
            System.out.println(test.next());
    }
}

Oder mit verkettbaren Factory-Methoden:

class Example {
    public static void main(String[] a) {
        Numbers test = Numbers.integers().squares().take(23);
        while (test.hasNext())
            System.out.println(test.next());
    }
}

Wo SquaresOf, Takeund IntegerserweiternNumbers

abstract class Numbers implements Iterator<Integer> {
    public static Numbers integers() {
        return new Integers();
    }

    public Numbers squares() {
        return new SquaresOf(this);
    }

    public Numbers take(int c) {
        return new Take(c, this);
    }
    public void remove() {}
}

1
Dies zeigt die Überlegenheit des OO-Paradigmas gegenüber dem funktionalen. Mit dem richtigen OO-Design können Sie ein Funktionsparadigma imitieren, aber Sie können kein OO-Paradigma in einem Funktionsstil imitieren.
m3th0dman

3
@ m3th0dman: Mit dem richtigen OO-Design können Sie FP möglicherweise halb nachahmen, genau wie jede Sprache, die Zeichenfolgen, Listen und / oder Wörterbücher enthält, halb nachahmen kann. Die Turing-Äquivalenz von Allzwecksprachen bedeutet, dass bei ausreichendem Aufwand jede Sprache die Merkmale jeder anderen simulieren kann.
CHAO

Beachten Sie, dass Iteratoren im Java-Stil wie in while (test.hasNext()) System.out.println(test.next())in FP ein No-No sind. Iteratoren sind von Natur aus veränderlich.
CHAO

1
@cHao Ich glaube kaum, dass echte Verkapselung oder Polymorphismus nachgeahmt werden können; auch Java (in diesem Beispiel) kann eine funktionale Sprache aufgrund der strengen eifrigen Bewertung nicht wirklich imitieren. Ich glaube auch, dass Iteratoren rekursiv geschrieben werden können.
m3th0dman

@ m3th0dman: Polymorphismus wäre überhaupt nicht schwer nachzuahmen; Sogar C und Assembler können das. Machen Sie die Methode einfach zu einem Feld im Objekt oder zu einem Klassendeskriptor / einer Klassentabelle. Und Verkapselung im Datum Versteck Sinne nicht unbedingt erforderlich ist; Die Hälfte der Sprachen bietet es nicht an. Wenn Ihr Objekt unveränderlich ist, spielt es keine Rolle, ob die Leute es überhaupt sehen können. Alles, was benötigt wird, ist Datenumbruch , den die oben erwähnten Methodenfelder leicht bereitstellen könnten.
CHAO

6

Kurzfassung:

Damit Single-Assignment-Stile in Java zuverlässig funktionieren, benötigen Sie (1) eine unveränderliche Infrastruktur und (2) Unterstützung auf Compiler- oder Laufzeitebene für die Tail-Call-Eliminierung.

Wir können einen Großteil der Infrastruktur schreiben und Dinge arrangieren, um zu vermeiden, dass der Stapel gefüllt wird. Solange jedoch jeder Aufruf einen Stack-Frame benötigt, ist die Anzahl der möglichen Rekursionen begrenzt. Halten Sie Ihre iterables klein und / oder faul, und Sie sollten keine größeren Probleme haben. Zumindest die meisten Probleme, auf die Sie stoßen, erfordern nicht die sofortige Rückgabe von einer Million Ergebnissen. :)

Beachten Sie auch, dass Sie nicht alles unveränderlich machen können, da das Programm sichtbare Änderungen vornehmen muss, damit es sich lohnt, ausgeführt zu werden . Sie können jedoch den Großteil Ihrer eigenen Daten unveränderlich halten, indem Sie nur an bestimmten wichtigen Stellen, an denen die Alternativen zu lästig wären, eine winzige Teilmenge der wesentlichen veränderlichen Daten (z. B. Streams) verwenden.


Lange Version:

Einfach ausgedrückt, ein Java-Programm kann Variablen nicht vollständig vermeiden, wenn es etwas tun möchte, das es wert ist, getan zu werden. Sie können enthalten und somit die Veränderlichkeit in hohem Maße einschränken, aber das eigentliche Design der Sprache und der API sowie die Notwendigkeit, das zugrunde liegende System zu ändern, machen eine vollständige Unveränderlichkeit unmöglich.

Java wurde von Anfang an als imperative , objektorientierte Sprache konzipiert.

  • Imperative Sprachen hängen fast immer von veränderlichen Variablen ab. Sie bevorzugen zum Beispiel die Iteration gegenüber der Rekursion und fast alle iterativen Konstrukte - sogar while (true)und for (;;)! - sind absolut abhängig von einer Variablen, die sich von Iteration zu Iteration ändert.
  • Objektorientierte Sprachen stellen sich fast jedes Programm als ein Diagramm von Objekten vor, die Nachrichten aneinander senden und in fast allen Fällen auf diese Nachrichten reagieren, indem sie etwas mutieren.

Das Endergebnis dieser Entwurfsentscheidungen ist, dass Java ohne veränderbare Variablen keine Möglichkeit hat, den Status von irgendetwas zu ändern - selbst von etwas so Einfachem wie dem Drucken von "Hallo Welt!" zum Bildschirm gehört ein Ausgabestream, bei dem Bytes in eine veränderbare Datei eingefügt werden Puffer .

Aus praktischen Gründen beschränken wir uns darauf, die Variablen aus unserem eigenen Code zu verbannen . OK, das können wir irgendwie machen. Fast. Grundsätzlich müssten wir fast alle Iterationen durch Rekursionen und alle Mutationen durch rekursive Aufrufe ersetzen, die den geänderten Wert zurückgeben. wie so ...

class Ints {
     final int value;
     final Ints tail;

     public Ints(int value, Ints rest) {
         this.value = value;
         this.tail = rest;
     }
     public Ints next() { return this.tail; }
     public int value() { return this.value; }
}

public Ints take(int count, Ints input) {
    if (count == 0 || input == null) return null;
    return new Ints(input.value(), take(count - 1, input.next()));
}    

public Ints squares_of(Ints input) {
    if (input == null) return null;
    int i = input.value();
    return new Ints(i * i, squares_of(input.next()));
}

Grundsätzlich erstellen wir eine verknüpfte Liste, wobei jeder Knoten eine Liste für sich ist. Jede Liste hat einen "Kopf" (den aktuellen Wert) und einen "Schwanz" (die verbleibende Unterliste). Die meisten funktionalen Sprachen tun etwas Ähnliches, weil sie einer effizienten Unveränderlichkeit sehr zugänglich sind. Eine "nächste" Operation gibt nur das Ende zurück, das normalerweise in einem Stapel rekursiver Aufrufe an die nächste Ebene übergeben wird.

Dies ist eine extrem vereinfachte Version dieses Materials. Aber es ist gut genug, um ein ernstes Problem mit diesem Ansatz in Java zu demonstrieren. Betrachten Sie diesen Code:

public function doStuff() {
    final Ints integers = ...somehow assemble list of 20 million ints...;
    final Ints result = take(25, squares_of(integers));
    ...
}

Obwohl wir nur 25 Zoll für das Ergebnis benötigen, squares_ofweiß das nicht. Es wird das Quadrat jeder Zahl in zurückgeben integers. Eine Rekursion mit einer Tiefe von 20 Millionen Ebenen verursacht in Java ziemlich große Probleme.

Sehen Sie, die funktionalen Sprachen, in denen Sie normalerweise so verrückt sind, haben eine Funktion namens "Tail Call Elimination". Dies bedeutet, dass der Compiler, wenn er sieht, dass der Code zuletzt sich selbst aufruft (und das Ergebnis zurückgibt, wenn die Funktion nicht ungültig ist), den Stapelrahmen des aktuellen Aufrufs verwendet, anstatt einen neuen zu erstellen, und stattdessen einen "Sprung" ausführt eines "Aufrufs" (so bleibt der verwendete Stapelspeicherplatz konstant). Kurz gesagt geht es um 90% des Weges, um die Schwanzrekursion in Iteration umzuwandeln. Es könnte mit diesen Milliarden-Ints umgehen, ohne den Stapel zu überschwemmen. (Es würde schließlich immer noch nicht genügend Arbeitsspeicher zur Verfügung stehen, aber das Zusammenstellen einer Liste mit einer Milliarde Ints wird Sie auf einem 32-Bit-System sowieso speicherintensiv machen.)

Java macht das in den meisten Fällen nicht. (Es hängt vom Compiler und der Laufzeit ab, aber die Oracle-Implementierung tut dies nicht.) Jeder Aufruf einer rekursiven Funktion beansprucht den Speicher eines Stack-Frames. Wenn Sie zu viel verbrauchen, kommt es zu einem Stapelüberlauf. Ein Überlaufen des Stapels garantiert jedoch den Tod des Programms. Also müssen wir sicherstellen, dass wir das nicht tun.

Eine Problemumgehung ... faule Auswertung. Wir haben immer noch die Stack-Beschränkungen, aber sie können mit Faktoren zusammenhängen, über die wir mehr Kontrolle haben. Wir müssen nicht eine Million Ints kalkulieren, um 25 zurückzugeben. :)

Bauen wir also eine Lazy-Evaluation-Infrastruktur auf. (Dieser Code wurde vor einiger Zeit getestet, aber ich habe ihn seitdem ziemlich verändert. Lies die Idee, nicht die Syntaxfehler. :))

// Represents something that can give us instances of OutType.
// We can basically treat this class like a list.
interface Source<OutType> {
     public Source<OutType> next();
     public OutType value();
}

// Represents an operation that turns an InType into an OutType.
// Note, these can be the same type.  We're just flexible like that.
interface Transform<InType, OutType> {
    public OutType appliedTo(InType input);
}

// Represents an action (as opposed to a function) that can run on
// every element of a sequence.
abstract class Action<InType> {
    abstract void doWith(final InType input);
    public void doWithEach(final Source<InType> input) {
        if (input == null) return;
        doWith(input.value());
        doWithEach(input.next());
    }
}

// A list of Integers.
class Ints implements Source<Integer> {
     final Integer value;
     final Ints tail;
     public Ints(Integer value, Ints rest) {
         this.value = value;
         this.tail = rest;
     }
     public Ints(Source<Integer> input) {
         this.value = input.value();
         this.tail = new Ints(input.next());
     }
     public Source<Integer> next() { return this.tail; }
     public Integer value() { return this.value; }
     public static Ints fromArray(Integer[] input) {
         return fromArray(input, 0, input.length);
     }
     public static Ints fromArray(Integer[] input, int start, int end) {
         if (end == start || input == null) return null;
         return new Ints(input[start], fromArray(input, start + 1, end));
     }
}

// An example of the spiff we get by splitting the "iterator" interface
// off.  These ints are effectively generated on the fly, as opposed to
// us having to build a huge list.  This saves huge amounts of memory
// and CPU time, for the rather common case where the whole sequence
// isn't needed.
class Range implements Source<Integer> {
    final int start, end;
    public Range(int start, int end) {
        this.start = start;
        this.end = end;
    }
    public Integer value() { return start; }
    public Source<Integer> next() {
        if (start >= end) return null;
        return new Range(start + 1, end);
    }
}

// This takes each InType of a sequence and turns it into an OutType.
// This *takes* a Transform, rather than just *implementing* Transform,
// because the transforms applied are likely to be specified inline.
// If we just let people override `value()`, we wouldn't easily know what type
// to return, and returning our own type would lose the transform method.
static class Mapper<InType, OutType> implements Source<OutType> {
    private final Source<InType> input;
    private final Transform<InType, OutType> transform;

    public Mapper(Transform<InType, OutType> transform, Source<InType> input) {
        this.transform = transform;
        this.input = input;
    }

    public Source<OutType> next() {
         return new Mapper<InType, OutType>(transform, input.next());
    }
    public OutType value() {
         return transform.appliedTo(input.value());
    }
}

// ...

public <T> Source<T> take(int count, Source<T> input) {
    if (count <= 0 || input == null) return null;
    return new Source<T>() {
        public T value() { return input.value(); }
        public Source<T> next() { return take(count - 1, input.next()); }
    };
}

(Denken Sie daran, dass, wenn dies in Java tatsächlich möglich wäre, Code, der zumindest in etwa der oben genannten Form entspricht, bereits Teil der API ist.)

Wenn eine Infrastruktur vorhanden ist, ist das Schreiben von Code, der keine veränderlichen Variablen benötigt und zumindest für kleinere Eingabemengen stabil ist, eher trivial.

public Source<Integer> squares_of(Source<Integer> input) {
     final Transform<Integer, Integer> square = new Transform<Integer, Integer>() {
         public Integer appliedTo(final Integer i) { return i * i; }
     };
     return new Mapper<>(square, input);
}


public void example() {
    final Source<Integer> integers = new Range(0, 1000000000);

    // and, as for the author's "bet you can't do this"...
    final Source<Integer> squares = take(25, squares_of(integers));

    // Just to make sure we got it right :P
    final Action<Integer> printAction = new Action<Integer>() {
        public void doWith(Integer input) { System.out.println(input); }
    };
    printAction.doWithEach(squares);
}

Dies funktioniert meistens, aber es ist immer noch etwas anfällig für Stapelüberläufe. Versuchen takeSie es mit 2 Milliarden Ints und machen Sie etwas dagegen. : P Irgendwann wird eine Ausnahme ausgelöst, mindestens bis 64 GB RAM zum Standard werden. Das Problem ist, dass der für den Stack reservierte Speicher eines Programms nicht so groß ist. Es liegt normalerweise zwischen 1 und 8 MiB. (Sie können für größere fragen, aber es spielt keine Rolle , dass alle viel , wie viel Sie verlangen - Sie rufen take(1000000000, someInfiniteSequence)Sie wird . Eine Ausnahme erhalten) Zum Glück, mit lazy evaluation, die Schwachstelle liegt in einem Gebiet können wir besser kontrollieren . Wir müssen nur vorsichtig sein, wie viel wir take().

Es wird immer noch viele Probleme beim Skalieren geben, da unsere Stapelverwendung linear zunimmt. Jeder Aufruf behandelt ein Element und leitet den Rest an einen anderen Aufruf weiter. Nun, da ich darüber nachdenke, gibt es einen Trick, den wir anwenden können, der uns möglicherweise mehr Spielraum verschafft: Die Anrufkette in einen Baum von Anrufen verwandeln . Betrachten Sie etwas Ähnliches:

public <T> void doSomethingWith(T input) { /* magic happens here */ }
public <T> Source<T> workWith(Source<T> input, int count) {
    if (count < 0 || input == null) return null;
    if (count == 0) return input;
    if (count == 1) {
        doSomethingWith(input.value());
        return input.next();
    }
    return (workWith(workWith(input, count/2), count - count/2);
}

workWithteilt die Arbeit im Grunde genommen in zwei Hälften auf und ordnet jede Hälfte einem anderen Aufruf zu. Da jeder Aufruf die Größe der Arbeitsliste eher um die Hälfte als um eins verringert, sollte dies logarithmisch und nicht linear skaliert werden.

Das Problem ist, dass diese Funktion eine Eingabe benötigt - und bei einer verknüpften Liste muss die gesamte Liste durchlaufen werden, um die Länge zu ermitteln. Das ist jedoch leicht zu lösen. Es ist einfach egal, wie viele Einträge es gibt. :) Der obige Code würde mit so etwas wie Integer.MAX_VALUEder Zählung funktionieren , da eine Null die Verarbeitung sowieso anhält. Die Zählung ist größtenteils vorhanden, sodass wir einen soliden Basisfall haben. Wenn Sie damit rechnen, mehr als Integer.MAX_VALUEEinträge in einer Liste zu haben, können Sie workWithden Rückgabewert überprüfen - er sollte am Ende null sein. Ansonsten rekursiv.

Denken Sie daran, dies berührt so viele Elemente, wie Sie es wünschen. Es ist nicht faul; es macht seine Sache sofort. Sie möchten dies nur für Aktionen ausführen, d. H. Für Objekte, deren einziger Zweck darin besteht, sich auf jedes Element in einer Liste anzuwenden. Wenn ich es mir gerade überlege, scheint es mir, dass Sequenzen viel weniger kompliziert wären, wenn sie linear gehalten würden. sollte kein Problem sein, da Sequenzen sich sowieso nicht selbst aufrufen - sie erstellen einfach Objekte, die sie erneut aufrufen.


3

Ich habe zuvor versucht, einen Interpreter für eine Lispel-ähnliche Sprache in Java zu erstellen (vor ein paar Jahren ging der gesamte Code verloren, wie er in CVS bei SourceForge war), und die Java-Utiliteratoren sind ein bisschen wortreich für die funktionale Programmierung auf Listen.

Hier ist etwas basierend auf einer Sequenzschnittstelle, die nur die beiden Operationen enthält, die Sie benötigen, um den aktuellen Wert abzurufen und die Sequenz ab dem nächsten Element abzurufen. Diese werden nach den Funktionen im Schema head und tail genannt.

Es ist wichtig, so etwas wie die Seqoder Iterator-Schnittstellen zu verwenden, da dies bedeutet, dass die Liste träge erstellt wird. DasIterator Schnittstelle kann kein unveränderliches Objekt sein und ist daher für die funktionale Programmierung weniger geeignet. Wenn Sie nicht feststellen können, ob der Wert, den Sie in eine Funktion eingeben, dadurch geändert wurde, verlieren Sie einen der Hauptvorteile der funktionalen Programmierung.

Sollte natürlich integerseine Liste aller ganzen Zahlen sein, so habe ich bei Null angefangen und abwechselnd positive und negative zurückgegeben.

Es gibt zwei Versionen von Quadraten - eine, die eine benutzerdefinierte Sequenz erstellt, die andere, mapdie eine 'Funktion' verwendet - Java 7 hat keine Lambdas, daher habe ich eine Schnittstelle verwendet - und wendet diese nacheinander auf jedes Element in der Sequenz an.

Der Zweck der square ( int x )Funktion besteht darin, nur die Notwendigkeit zu beseitigen, head()zweimal aufzurufen - normalerweise hätte ich dies getan, indem ich den Wert in eine endgültige Variable eingegeben hätte, aber das Hinzufügen dieser Funktion bedeutet, dass das Programm keine Variablen enthält, sondern nur Funktionsparameter.

Die Ausführlichkeit von Java für diese Art der Programmierung veranlasste mich, stattdessen die zweite Version meines Interpreters in C99 zu schreiben.

public class Squares {
    interface Seq<T> {
        T head();
        Seq<T> tail();
    }

    public static void main (String...args) {
        print ( take (25, integers ) );
        print ( take (25, squaresOf ( integers ) ) );
        print ( take (25, squaresOfUsingMap ( integers ) ) );
    }

    static Seq<Integer> CreateIntSeq ( final int n) {
        return new Seq<Integer> () {
            public Integer head () {
                return n;
            }
            public Seq<Integer> tail () {
                return n > 0 ? CreateIntSeq ( -n ) : CreateIntSeq ( 1 - n );
            }
        };
    }

    public static final Seq<Integer> integers = CreateIntSeq(0);

    public static Seq<Integer> squaresOf ( final Seq<Integer> source ) {
        return new Seq<Integer> () {
            public Integer head () {
                return square ( source.head() );
            }
            public Seq<Integer> tail () {
                return squaresOf ( source.tail() );
            }
        };
    }

    // mapping a function over a list rather than implementing squaring of each element
    interface Fun<T> {
        T apply ( T value );
    }

    public static Seq<Integer> squaresOfUsingMap ( final Seq<Integer> source ) {
        return map ( new Fun<Integer> () {
            public Integer apply ( final Integer value ) {
                return square ( value );
            }
        }, source );
    }

    public static <T> Seq<T> map ( final Fun<T> fun, final Seq<T> source ) {
        return new Seq<T> () {
            public T head () {
                return fun.apply ( source.head() );
            }
            public Seq<T> tail () {
                return map ( fun, source.tail() );
            }
        };
    }

    public static Seq<Integer> take ( final int count,  final Seq<Integer> source ) {
        return new Seq<Integer> () {
            public Integer head () {
                return source.head();
            }
            public Seq<Integer> tail () {
                return count > 0 ? take ( count - 1, source.tail() ) : nil;
            }
        };
    }

    public static int square ( final int x ) {
        return x * x;
    }

    public static final Seq<Integer> nil = new Seq<Integer> () {
        public Integer head () {
            throw new RuntimeException();
        }
        public Seq<Integer> tail () {
            return this;
        }
    };

    public static <T> void print ( final Seq<T> seq ) {
        printPartSeq ( "[", seq.head(), seq.tail() );
    }

    private static <T> void printPartSeq ( final String prefix, final T value, final Seq<T> seq ) {
        if ( seq == nil) {
            System.out.println("]");
        } else {
            System.out.print(prefix);
            System.out.print(value);
            printPartSeq ( ",", seq.head(), seq.tail() );
        }
    }
}

3

Wie man nützliche Java-Programme schreibt , ohne veränderbare Variablen zu verwenden.

Sie können theoretisch so gut wie alles in Java implementieren, indem Sie nur eine Rekursion und keine veränderlichen Variablen verwenden.

In der Praxis:

  • Die Java-Sprache ist dafür nicht ausgelegt. Viele Konstrukte sind für die Mutation konzipiert und ohne sie schwer zu verwenden. (Beispielsweise können Sie ein Java-Array mit variabler Länge nicht ohne Mutation initialisieren.)

  • Das Gleiche gilt für die Bibliotheken. Und wenn Sie sich auf Bibliotheksklassen beschränken, die keine Mutation unter dem Deckmantel verwenden, ist es noch schwieriger. (Sie können nicht einmal String verwenden ... schauen Sie sich an, wie hashcodees implementiert ist.)

  • Mainstream-Java-Implementierungen unterstützen keine Tail-Call-Optimierung. Das bedeutet, dass rekursive Versionen von Algorithmen dazu neigen, Stapelspeicherplatz "hungrig" zu machen. Und da Java-Thread-Stapel nicht wachsen, müssen Sie große Stapel vorbelegen ... oder Risiken eingehen StackOverflowError.

Kombinieren Sie diese drei Dinge, und Java ist keine wirklich praktikable Option, um nützliche (dh nicht triviale) Programme ohne veränderbare Variablen zu schreiben .

(Aber hey, das ist in Ordnung. Es gibt andere Programmiersprachen für die JVM, von denen einige die funktionale Programmierung unterstützen.)


2

Da wir nach einem Beispiel für die Konzepte suchen, lassen Sie uns Java beiseite legen und nach einer anderen, aber vertrauten Einstellung suchen, in der Sie eine vertraute Version der Konzepte finden. UNIX-Pipes ähneln eher der Verkettung von Lazy Functions.

cat /dev/zero | tr '\0' '\n' | cat -n | awk '{ print $0 * $0 }' | head 25

In Linux bedeutet dies, dass Sie mir Bytes geben, von denen jedes aus falschen und nicht aus wahren Bits besteht, bis ich den Appetit verliere. Ändern Sie jedes dieser Bytes in ein Zeilenumbruchszeichen. nummerieren Sie jede so erzeugte Zeile; und das Quadrat dieser Zahl erzeugen. Außerdem habe ich Appetit auf 25 Zeilen und nicht mehr.

Ich behaupte, ein Programmierer wäre nicht schlecht beraten, eine Linux-Pipeline auf diese Weise zu schreiben. Es ist ein relativ normales Linux-Shell-Scripting.

Ich behaupte, ein Programmierer wäre schlecht beraten, das Gleiche in Java zu schreiben. Der Grund dafür ist die Softwarewartung als Hauptfaktor für die Lebenszykluskosten von Softwareprojekten. Wir möchten den nächsten Programmierer nicht verwirren, indem wir das, was angeblich ein Java-Programm ist, präsentieren. Tatsächlich wird es in einer benutzerdefinierten Sprache geschrieben, indem Funktionen, die bereits auf der Java-Plattform vorhanden sind, aufwendig dupliziert werden.

Andererseits behaupte ich, dass der nächste Programmierer akzeptabler sein könnte, wenn einige unserer "Java" -Pakete tatsächlich Java Virtual Machine-Pakete sind, die in einer der Funktions- oder Objekt- / Funktionssprachen wie Clojure und Scala geschrieben sind. Diese sind so konzipiert, dass sie durch Verketten von Funktionen codiert werden und in der normalen Art und Weise von Java-Methodenaufrufen aus aufgerufen werden.

Andererseits kann es für einen Java-Programmierer immer noch eine gute Idee sein, sich an einigen Stellen von der funktionalen Programmierung inspirieren zu lassen.

Kürzlich bestand meine Lieblingstechnik darin, eine unveränderbare, nicht initialisierte Rückgabevariable und einen einzelnen Exit zu verwenden, so dass Java, wie es einige Compiler für funktionale Sprachen tun, überprüft, dass ich immer nur einen einzigen zur Verfügung stelle, unabhängig davon, was im Funktionskörper passiert Rückgabewert. Beispiel:

int f(final int n) {
    final int result; // not initialized here!
    if (n < 0) {
        result = -n;
    } else if (n < 1) {
        result = 0;
    } else {
        result = n - 1;
    }
    // If I would leave off the "else" clause,
    // Java would fail to compile complaining that
    // "result" is possibly uninitialized.
    return result;
}


Ich bin ungefähr zu 70% sicher, dass Java die Rückgabewertprüfung sowieso bereits durchführt. Sie sollten eine Fehlermeldung über eine "fehlende return-Anweisung" erhalten, wenn die Kontrolle vom Ende einer nicht ungültigen Funktion abfallen kann.
cHao

Mein Punkt: Wenn Sie es während der int result = -n; if (n < 1) { result = 0 } return result;Kompilierung codieren und der Compiler keine Ahnung hat, ob Sie beabsichtigen, es der Funktion in meinem Beispiel gleichzusetzen. Vielleicht ist dieses Beispiel zu einfach, um die Technik hilfreich erscheinen zu lassen, aber in einer Funktion mit vielen Verzweigungen finde ich es gut, klar zu machen, dass das Ergebnis genau einmal zugewiesen wird, unabhängig davon, welchem ​​Pfad gefolgt wird.
Minopret

Wenn Sie jedoch sagen if (n < 1) return 0; else return -n;, dass Sie kein Problem haben ... und es ist außerdem einfacher. :) Scheint mir, dass in diesem Fall die "One Return" -Regel tatsächlich dazu beiträgt, dass nicht bekannt ist, wann Ihr Rückgabewert festgelegt wurde; Andernfalls könnten Sie es einfach zurückgeben, und Java wäre in der Lage, festzustellen, wann andere Pfade möglicherweise keinen Wert zurückgeben, da Sie die Berechnung des Werts nicht mehr von der tatsächlichen Rückgabe des Werts trennen.
CHAO

Oder als Beispiel für Ihre Antwort if (n < 0) return -n; else if (n == 0) return 0; else return n - 1;.
CHAO

Ich habe nur beschlossen, keine weiteren Momente meines Lebens damit zu verbringen, die OnlyOneReturn-Regel in Java zu verteidigen. Raus geht es. Wann und wenn ich an eine Java-Codierungspraxis denke, die meiner Meinung nach von funktionalen Programmierpraktiken beeinflusst wird, werde ich dieses Beispiel ersetzen. Bis dahin kein Beispiel.
Minopret

0

Der einfachste Weg, dies herauszufinden, wäre, dem Frege- Compiler die folgenden Informationen zu übermitteln und den generierten Java-Code zu betrachten:

module Main where

result = take 25 (map sqr [1..]) where sqr x = x*x

Nach ein paar Tagen kehrten meine Gedanken zu dieser Antwort zurück. Schließlich war ein Teil meines Vorschlags, die funktionalen Programmierteile in Scala zu implementieren. Wenn wir darüber nachdenken, Scala an den Stellen anzuwenden, an denen wir wirklich Haskell im Sinn hatten (und ich denke, ich bin nicht der einzige blog.zlemma.com/2013/02/20/… ), sollten wir nicht zumindest Frege in Betracht ziehen?
Minopret

@minopret Dies ist in der Tat die Nische, die Frege tragisch macht - Leute, die Haskell kennen und lieben gelernt haben und dennoch die JVM brauchen. Ich bin zuversichtlich, dass Frege eines Tages reif genug sein wird, um zumindest ernsthafte Überlegungen anzustellen.
Ingo
Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.