Корректное изменение состояния в Scala

#scala #functional-programming #state

#скала #функциональное программирование #состояние

Вопрос:

Я хочу организовать свою программу вокруг неизменяемого состояния (используя класс case) и цепочки функций, которые его обновляют.

 case class State(conn: Connection, file: FileInputStream, data: Data)
 

Первые две функции в этой цепочке просто открывают соединение и входной поток, а остальные используют эти ресурсы. Итак, это мои варианты, как я их вижу:

  1. Использование нулей при инициализации состояния:
     State(conn = null, file = null, data = new Data())
    .pipe(getConnection)
    .pipe(openStream)
    .pipe(processData)
    .pipe(doCleanUp)
 

Где состояние обновляется в подобных функциях:

   val newConn = ???
  st.copy(conn = newConn)
 

Таким образом, код остается относительно чистым, но если порядок функций будет изменен в будущем, я столкнусь с ужасным NullPointerException.

  1. Объявление conn и file в качестве параметров и перенос состояния в любой из них.
     case class State(conn: Option[Connection], file: Option[FileInputStream], data: Data)
    Right(State(conn = null, file = null, data = new Data()))
    .pipe(...

 

При этом проблема с nulls элегантно решается, но все функции, которые имеют дело с данными, должны реализовывать большую часть шаблонного кода, чтобы развернуть параметры.

   {
    case Left(ex) => Left(ex)
    case Right(st) if st.conn.isEmpty => Left(new Exception("Connection is missing"))
    case Right(st) if st.file.isEmpty => Left(new Exception("File stream is missing"))
    case Right(st) =>
      ...
  }
 

Результирующий код является более надежным и функциональным, но такой шаблон в каждой функции немного утомителен. Есть ли более идиоматичный и гибкий подход к этой проблеме?

Комментарии:

1. Входной поток является изменяемым, поэтому вы ничего не выигрываете, перенося его в класс case. Если вы хотите правильно следовать этому стилю, я бы порекомендовал вам взглянуть на потоковые реализации, такие как fs2 , AkkaStreams , Monix и т. Д.

Ответ №1:

Я думаю, вы переоцениваете это… на самом деле это не состояние, а всего лишь пара параметров (состояние — это то, что мутирует во время процесса, здесь это не так).

Почему бы просто не сделать параметры явными:

 def openConnection(): Connection = ??? 
def openStream(c: Connection): InputStream = ??? 
def processData(stream: InputStream, data: Data): Unit = ??? 
def cleanup(c: Connection): Unit
 

Таким образом, вам не нужны уродливые нули, и никто не сможет вызвать эти
функции неправильно или в неправильном порядке по ошибке.

Вы можете сделать его более красивым / причудливым, если обернете возвращаемые значения в некоторую монаду ( Option Future возможно, или, возможно Try , или Either для обработки ошибок):

 def openConnection(): Future[Connection] = ??? 
def openStream(c: Connection): Future[InputStream] = ??? 
def processData(stream: InputStream, data: Data): Future[Unit] = ???
def cleanup(stream: InputStream): Unit
 

Теперь вы можете написать это как

    val result: Future[Unit] = openConnection()
      .flatMap(openStream)
      .flatMap { stream => 
         val f = processData(stream, data)
           .onComplete(cleanup(stream)
         f // it's too bad `.onComplete` doesn't return `this` would make things like this look a lot nicer :(
      }
 

Ответ №2:

Вы должны использовать .map вместо .pipe при работе с эффектами, такими как Option или Either . Есть много хороших руководств и примеров эффектов и того, как с ними обращаться. Мне действительно нравится подход Скотта Влашина к обучению. Вы можете найти его на YouTube здесь.

В вашем примере вы можете сделать что-то вроде этого:

 Right(State(conn = None, file = None data = new Data()))
    .map(getConnection)
    .map(openStream)
    .map(...)
 

.map запускает функцию для a State , если это a Right , и игнорирует ее с помощью a Left .

Кстати, вы действительно никогда не должны использовать null в Scala. Используется None для представления отсутствующего значения.